feat: add "Anthropic — via Claude CLI" as a first-class provider

Replaces the stray useClaudeCli settings checkbox + onboarding question
with a proper provider-card UX. The card lives next to OAuth + API-key
cards in onboarding and settings, with Enable/Disable + Test actions.

Backend:
 - Vendors rchern/pi-claude-cli@0.3.1 as packages/pi-claude-cli
   (MIT, attribution in UPSTREAM.md). Lets us bump peer-dep on
   pi-coding-agent in lockstep with Fusion (upstream pinned ^0.52.0
   vs ours ^0.62.0) and fix bugs without waiting on upstream.
 - Adds @fusion/pi-claude-cli as a workspace dep of @runfusion/fusion
   so users don't have to `npm install -g pi-claude-cli` manually.
 - serve/daemon/dashboard conditionally load the extension via
   discoverAndLoadExtensions() when GlobalSettings.useClaudeCli is on;
   no side-effects on user ~/.fusion/agent/settings.json.
 - New GET /api/providers/claude-cli/status: claude --version probe
   + toggle state + cached extension resolution.
 - New POST /api/auth/claude-cli: flips useClaudeCli, refuses if the
   claude binary is missing, fires the existing skill-backfill hook.
 - /api/auth/status now injects a synthetic {id:"claude-cli", type:"cli"}
   provider entry so onboarding + settings see a consistent list.

Frontend:
 - New ClaudeCliProviderCard component shared between ModelOnboardingModal
   and SettingsModal's Authentication section.
 - New AuthProvider.type = "cli" variant.
 - Removed the old "Route AI calls through the Claude CLI" checkbox from
   Global Models settings and the opt-in step from the onboarding wizard.
 - ProviderIcon gets a composite Anthropic-mark-plus-terminal glyph for
   the claude-cli provider id.

Tests:
 - 8 unit tests for extension resolution (@fusion/pi-claude-cli is
   workspace-linked so these run in-tree).
 - 2 unit tests for the binary probe.
 - Existing /auth/status tests filter out the new synthetic entry so
   they keep asserting structural OAuth/API-key behavior in isolation.
 - The vendored package's own 296 tests still pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-23 16:48:57 -07:00
parent 82b4c696d8
commit f4e0850f9d
44 changed files with 9390 additions and 76 deletions

View File

@@ -926,12 +926,56 @@ export interface AuthProvider {
id: string;
name: string;
authenticated: boolean;
/** Whether this provider uses OAuth or API key authentication */
type?: "oauth" | "api_key";
/**
* How this provider authenticates / is activated.
* - "oauth": OAuth flow (user clicks Login → redirect)
* - "api_key": API key stored locally
* - "cli": a locally-installed CLI binary is the backing transport
* (e.g. the synthetic `claude-cli` provider). Cards should render a
* one-click Enable/Disable + Test button rather than login/key inputs.
*/
type?: "oauth" | "api_key" | "cli";
/** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */
keyHint?: string;
}
/**
* Snapshot of the Claude-CLI-via-pi health state. Powers the
* "Anthropic — via Claude CLI" provider card.
*/
export interface ClaudeCliStatus {
binary: {
available: boolean;
version?: string;
binaryPath?: string;
reason?: string;
probeDurationMs: number;
};
enabled: boolean;
extension: {
status: "ok" | "not-installed" | "missing-entry" | "error";
path?: string;
packageVersion?: string;
reason?: string;
} | null;
ready: boolean;
}
/** Probe the local Claude CLI binary + setting + extension state. */
export function fetchClaudeCliStatus(): Promise<ClaudeCliStatus> {
return api<ClaudeCliStatus>("/providers/claude-cli/status");
}
/** Enable or disable the Claude CLI provider. Refuses enable if binary is missing. */
export function setClaudeCliEnabled(
enabled: boolean,
): Promise<{ enabled: boolean; restartRequired: boolean }> {
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/claude-cli", {
method: "POST",
body: JSON.stringify({ enabled }),
});
}
/** Fetch authentication status for all OAuth providers */
export function fetchAuthStatus(): Promise<{
providers: AuthProvider[];

View File

@@ -0,0 +1,270 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import {
fetchClaudeCliStatus,
setClaudeCliEnabled,
type ClaudeCliStatus,
} from "../api";
import { ProviderIcon } from "./ProviderIcon";
/**
* "Anthropic — via Claude CLI" provider card.
*
* Shown alongside the OAuth + API-key provider cards in onboarding and
* settings. Wraps three actions:
*
* 1. **Test** — polls GET /providers/claude-cli/status to re-probe the
* claude binary. Surfaces the binary path, version, and any reason
* it's unreachable.
* 2. **Enable / Disable** — POST /auth/claude-cli to flip
* GlobalSettings.useClaudeCli. Refused server-side if the binary is
* missing. On transition the server fires the same hook PUT
* /settings/global fires, so skills get backfilled into every
* registered project immediately.
* 3. **Surface "restart required"** — pi extension registrations can't
* be swapped mid-process, so the model-routing change only takes
* effect on next Fusion restart. We show that explicitly rather
* than letting users wonder why their model picker still shows
* non-Anthropic entries right after clicking Enable.
*
* The card avoids rendering as "authenticated" on its own — that state
* comes from the AuthProvider entry in the parent component's list so
* every consumer (onboarding, settings) shows the same truth.
*/
interface ClaudeCliProviderCardProps {
/** Authenticated flag from the parent AuthProvider entry. */
authenticated: boolean;
/** Optional callback fired after Enable/Disable to let the parent refetch the provider list. */
onToggled?: (nextEnabled: boolean) => void;
}
export function ClaudeCliProviderCard({
authenticated,
onToggled,
}: ClaudeCliProviderCardProps) {
const [status, setStatus] = useState<ClaudeCliStatus | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(
null,
);
const [lastAction, setLastAction] = useState<
| { kind: "enabled"; restartRequired: boolean }
| { kind: "disabled"; restartRequired: boolean }
| { kind: "error"; message: string }
| null
>(null);
// Guard against state updates after unmount — React complains otherwise.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const refresh = useCallback(async () => {
try {
const next = await fetchClaudeCliStatus();
if (mountedRef.current) setStatus(next);
return next;
} catch (err) {
if (mountedRef.current) {
setLastAction({
kind: "error",
message: err instanceof Error ? err.message : String(err),
});
}
return null;
}
}, []);
// Initial probe — cheap, happens once per mount.
useEffect(() => {
void refresh();
}, [refresh]);
const handleTest = useCallback(async () => {
setBusy("testing");
setLastAction(null);
await refresh();
if (mountedRef.current) setBusy(null);
}, [refresh]);
const handleToggle = useCallback(
async (next: boolean) => {
setBusy(next ? "enabling" : "disabling");
setLastAction(null);
try {
const result = await setClaudeCliEnabled(next);
if (mountedRef.current) {
setLastAction({
kind: result.enabled ? "enabled" : "disabled",
restartRequired: result.restartRequired,
});
}
onToggled?.(result.enabled);
await refresh();
} catch (err) {
if (mountedRef.current) {
setLastAction({
kind: "error",
message: err instanceof Error ? err.message : String(err),
});
}
} finally {
if (mountedRef.current) setBusy(null);
}
},
[onToggled, refresh],
);
const binaryAvailable = status?.binary.available ?? false;
const currentlyEnabled = status?.enabled ?? authenticated;
return (
<div
className={`onboarding-provider-card${authenticated ? " onboarding-provider-card--connected" : ""}`}
data-testid="claude-cli-provider-card"
>
<div className="onboarding-provider-card__icon">
<ProviderIcon provider="claude-cli" size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">
Anthropic via Claude CLI
</strong>
<span className="onboarding-provider-card__description">
Route AI calls through your locally-installed <code>claude</code> CLI.
Uses your existing Claude subscription / quota instead of an API key.
</span>
<ClaudeCliStatusLine status={status} authenticated={authenticated} />
</div>
<div className="onboarding-provider-card__actions">
<button
type="button"
className="btn btn-sm"
onClick={handleTest}
disabled={busy !== null}
>
{busy === "testing" ? (
<>
<Loader2 size={12} className="animate-spin" />
Testing
</>
) : (
"Test"
)}
</button>
{currentlyEnabled ? (
<button
type="button"
className="btn btn-sm"
onClick={() => void handleToggle(false)}
disabled={busy !== null}
>
{busy === "disabling" ? "Disabling…" : "Disable"}
</button>
) : (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => void handleToggle(true)}
disabled={busy !== null || !binaryAvailable}
title={
!binaryAvailable
? "`claude` binary not detected on PATH — install Claude CLI first."
: undefined
}
>
{busy === "enabling" ? "Enabling…" : "Enable"}
</button>
)}
</div>
{lastAction && (
<ClaudeCliActionToast action={lastAction} />
)}
</div>
);
}
/**
* One-line health summary. Renders different text for "binary missing"
* vs "binary ok but disabled" vs "fully ready" so the user can quickly
* see why the provider is or isn't working.
*/
function ClaudeCliStatusLine({
status,
authenticated,
}: {
status: ClaudeCliStatus | null;
authenticated: boolean;
}) {
if (!status) {
return (
<small className="settings-muted">
<Loader2 size={10} className="animate-spin" /> Probing local CLI
</small>
);
}
const { binary, enabled, extension, ready } = status;
if (!binary.available) {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--error">
{binary.reason ?? "`claude` not found on PATH"}
</small>
);
}
if (!enabled) {
return (
<small className="settings-muted">
<code>claude</code> {binary.version ? `(${binary.version})` : ""} detected
{binary.binaryPath ? ` at ${binary.binaryPath}` : ""}. Click Enable to
route AI calls through it.
</small>
);
}
if (extension && extension.status !== "ok") {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--warning">
Extension load failed: {extension.reason ?? extension.status}
</small>
);
}
if (ready || authenticated) {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--connected">
Connected{binary.version ? `${binary.version}` : ""}
</small>
);
}
return (
<small className="settings-muted">
Enabled. Restart Fusion to complete activation.
</small>
);
}
function ClaudeCliActionToast({
action,
}: {
action:
| { kind: "enabled"; restartRequired: boolean }
| { kind: "disabled"; restartRequired: boolean }
| { kind: "error"; message: string };
}) {
if (action.kind === "error") {
return (
<p className="onboarding-helper-text" style={{ color: "var(--danger)" }}>
{action.message}
</p>
);
}
const verb = action.kind === "enabled" ? "Enabled" : "Disabled";
return (
<p className="onboarding-helper-text">
{verb}.{" "}
{action.restartRequired
? "Restart Fusion to activate the routing change."
: "No further action needed."}
</p>
);
}

View File

@@ -15,6 +15,7 @@ import {
import type { ToastType } from "../hooks/useToast";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { appendTokenQuery } from "../auth";
/** Provider-specific API key setup metadata for onboarding form rendering */
@@ -493,7 +494,6 @@ export function ModelOnboardingModal({
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [useClaudeCli, setUseClaudeCli] = useState<boolean>(false);
const [saving, setSaving] = useState(false);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
@@ -1151,20 +1151,13 @@ export function ModelOnboardingModal({
}
}
// Only write useClaudeCli when the user explicitly opted in; leaving
// the field undefined keeps the legacy packages-array fallback in play
// for anyone who had pi-claude-cli installed before this toggle existed.
if (useClaudeCli) {
updates.useClaudeCli = true;
}
await updateGlobalSettings(updates);
// Mark onboarding as completed (preserves state for completion timestamp)
markOnboardingCompleted();
} catch {
// Best-effort: continue even if save fails
}
}, [selectedModel, availableModels, useClaudeCli, updateGlobalSettings, markOnboardingCompleted]);
}, [selectedModel, availableModels, updateGlobalSettings, markOnboardingCompleted]);
// Complete onboarding
const handleComplete = useCallback(async () => {
@@ -1296,6 +1289,7 @@ export function ModelOnboardingModal({
(p) => !p.type || p.type === "oauth",
);
const apiKeyProviders = authProviders.filter((p) => p.type === "api_key");
const cliProviders = authProviders.filter((p) => p.type === "cli");
// Filter out GitHub from AI providers list
const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github");
@@ -1716,6 +1710,23 @@ export function ModelOnboardingModal({
</>
)}
{/* Claude CLI — synthetic provider card. Rendered alongside
OAuth + API-key cards but with its own action set
(Enable/Disable + Test) since it's backed by a binary
probe rather than stored credentials. */}
{cliProviders.some((p) => p.id === "claude-cli") && (
<ClaudeCliProviderCard
authenticated={
cliProviders.find((p) => p.id === "claude-cli")?.authenticated ?? false
}
onToggled={() => {
// Refetch auth status so the parent provider list
// reflects the new authenticated state.
void loadAuthStatus();
}}
/>
)}
{/* Model Selection */}
<div className="onboarding-model-section">
<h3 className="onboarding-section-title">
@@ -1761,38 +1772,6 @@ export function ModelOnboardingModal({
)}
</div>
{/* Claude CLI routing toggle.
Opt-in: we only flip useClaudeCli=true when the user ticks
this. The backend install hooks (fn init, dashboard project
add, server startup) will then symlink the fusion skill into
every project's .claude/skills/fusion so Claude Code can see
fn_* tools. */}
<div className="onboarding-model-section">
<h3 className="onboarding-section-title">
Use the Claude CLI? (Optional)
</h3>
<label htmlFor="onboarding-useClaudeCli" className="checkbox-label">
<input
id="onboarding-useClaudeCli"
type="checkbox"
checked={useClaudeCli}
onChange={(e) => setUseClaudeCli(e.target.checked)}
/>
Route AI calls through my locally-installed Claude CLI
</label>
<OnboardingDisclosure summary="What does this do?">
<p className="onboarding-helper-text">
If you already have Claude CLI installed and an active
Claude subscription, Fusion can send its model calls through
the CLI instead of the Anthropic API using your existing
quota. Requires the <code>pi-claude-cli</code> pi extension.
Fusion will also install its skill into each project's{" "}
<code>.claude/skills/fusion/</code> so Claude Code can use
Fusion's tools directly. You can toggle this later in
Settings Global Models.
</p>
</OnboardingDisclosure>
</div>
</div>
)}

View File

@@ -155,11 +155,46 @@ function KimiIcon({ size, color, label = "Kimi" }: { size: number; color: string
);
}
// Anthropic "A" mark composited with a small terminal "> _" badge in the
// bottom-right, visually signalling "Anthropic, but via the local CLI".
function ClaudeCliIcon({ size, color, label = "Anthropic — via Claude CLI" }: { size: number; color: string; label?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-testid="claude-cli-icon"
aria-label={label}
>
{/* Anthropic "A" mark, slightly shrunk + shifted to leave room for the badge */}
<g transform="translate(-1 -1.5) scale(0.82)">
<path
d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"
fill={color}
/>
</g>
{/* Terminal badge — filled square with "> _" glyph */}
<rect x="13" y="13" width="10" height="9" rx="1.5" fill={color} />
<path
d="M15.2 16.2l1.6 1.4-1.6 1.4M18.6 19.6h2.4"
stroke="var(--bg-primary, #111)"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
}
const providerConfig: Record<
string,
{ component: typeof AnthropicIcon; color: string; label?: string }
> = {
anthropic: { component: AnthropicIcon, color: "#d4a27f" }, // warm tan
"claude-cli": { component: ClaudeCliIcon, color: "#d4a27f", label: "Anthropic — via Claude CLI" },
openai: { component: OpenAIIcon, color: "#10a37f" }, // green
"openai-codex": { component: OpenAIIcon, color: "#10a37f", label: "OpenAI Codex" }, // green (same as openai)
google: { component: GeminiIcon, color: "#4285f4" }, // blue

View File

@@ -11,6 +11,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { FileEditor } from "./FileEditor";
import { PluginManager } from "./PluginManager";
import { PiExtensionsManager } from "./PiExtensionsManager";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { PluginSlot } from "./PluginSlot";
import { AgentPromptsManager } from "./AgentPromptsManager";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
@@ -1225,27 +1226,6 @@ export function SettingsModal({
</small>
</div>
{/* --- Claude CLI routing --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Claude CLI</h4>
<div className="form-group">
<label htmlFor="useClaudeCli" className="checkbox-label">
<input
id="useClaudeCli"
type="checkbox"
checked={form.useClaudeCli === true}
onChange={(e) => setForm((f) => ({ ...f, useClaudeCli: e.target.checked }))}
/>
Route AI calls through the Claude CLI (via pi-claude-cli)
</label>
<small>
When enabled, Fusion sends model calls to your locally-installed Claude CLI instead
of the direct Anthropic API useful if you already have a Claude subscription and
want to use its quota. Requires <code>pi-claude-cli</code> installed as a pi
extension. Fusion will also install its skill into each project's{" "}
<code>.claude/skills/fusion/</code> so Claude Code sessions can use the{" "}
<code>fn_*</code> tools natively.
</small>
</div>
</>
);
}
@@ -3045,8 +3025,14 @@ export function SettingsModal({
case "pi-extensions":
return <PiExtensionsManager addToast={addToast} projectId={projectId} />;
case "authentication":
// CLI-backed providers (currently just claude-cli) render their own
// compact card with Enable/Disable + Test actions — bypassing the
// OAuth/API-key rendering below. Filter them out of the standard
// sort and render alongside.
const cliAuthProviders = authProviders.filter((p) => p.type === "cli");
const nonCliProviders = authProviders.filter((p) => p.type !== "cli");
// Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name.
const sortedProviders = [...authProviders].sort((a, b) => {
const sortedProviders = [...nonCliProviders].sort((a, b) => {
if (a.authenticated !== b.authenticated) {
return a.authenticated ? -1 : 1;
}
@@ -3227,6 +3213,22 @@ export function SettingsModal({
</small>
</div>
)}
{cliAuthProviders.some((p) => p.id === "claude-cli") && (
<div className="auth-provider-group">
<div className="auth-group-label">Local CLI</div>
<ClaudeCliProviderCard
authenticated={
cliAuthProviders.find((p) => p.id === "claude-cli")
?.authenticated ?? false
}
onToggled={() => {
// Refetch so the provider list reflects the new state.
void loadAuthStatus();
}}
/>
</div>
)}
</>
);
}