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 64d829f924
commit 32da0aedac
44 changed files with 9390 additions and 76 deletions

View File

@@ -45,6 +45,7 @@
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/pi-claude-cli": "workspace:*",
"@mariozechner/pi-ai": "^0.62.0",
"@mariozechner/pi-coding-agent": "^0.62.0",
"express": "^5.1.0",

View File

@@ -0,0 +1,94 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import {
resolveClaudeCliExtension,
resolveClaudeCliExtensionPaths,
} from "./claude-cli-extension.js";
describe("resolveClaudeCliExtension", () => {
it("finds the bundled @fusion/pi-claude-cli package", () => {
const result = resolveClaudeCliExtension();
// In the monorepo test environment, the workspace package MUST resolve.
// If this fails, the vendored package's package.json or pi.extensions
// entry has been broken — a real regression worth surfacing.
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.path).toMatch(/pi-claude-cli[\/\\]index\.ts$/);
expect(result.packageVersion).toMatch(/^\d+\.\d+\.\d+$/);
}
});
});
describe("resolveClaudeCliExtensionPaths", () => {
it("returns empty when useClaudeCli is off (default)", () => {
const result = resolveClaudeCliExtensionPaths({});
expect(result.paths).toEqual([]);
expect(result.warning).toBeUndefined();
expect(result.resolution).toBeNull();
});
it("returns empty when useClaudeCli is explicitly false", () => {
const result = resolveClaudeCliExtensionPaths({ useClaudeCli: false });
expect(result.paths).toEqual([]);
expect(result.resolution).toBeNull();
});
it("returns empty when useClaudeCli is a non-boolean truthy value", () => {
// Defensive: API might pass strings, numbers — we only activate on true.
const result = resolveClaudeCliExtensionPaths({
useClaudeCli: "true" as unknown as boolean,
});
expect(result.paths).toEqual([]);
});
it("returns the resolved path when useClaudeCli is on", () => {
const result = resolveClaudeCliExtensionPaths({ useClaudeCli: true });
expect(result.paths).toHaveLength(1);
expect(result.paths[0]).toMatch(/pi-claude-cli[\/\\]index\.ts$/);
expect(result.resolution?.status).toBe("ok");
});
it("surfaces a warning but does not throw on weird inputs", () => {
// Exercises the defensive null/undefined/garbage handling — callers
// pass settings from disk that could be corrupt.
// @ts-expect-error intentionally bad shape
const result = resolveClaudeCliExtensionPaths(null);
expect(result.paths).toEqual([]);
});
});
describe("cached resolution roundtrip", () => {
it("set/get preserves the snapshot", async () => {
const { setCachedClaudeCliResolution, getCachedClaudeCliResolution } =
await import("./claude-cli-extension.js");
setCachedClaudeCliResolution({ status: "not-installed" });
expect(getCachedClaudeCliResolution()).toEqual({ status: "not-installed" });
setCachedClaudeCliResolution(null);
expect(getCachedClaudeCliResolution()).toBeNull();
});
});
// Directory-fixture smoke test: give the resolver a minimal "fake" package
// layout to prove it handles malformed installs gracefully. This doesn't
// use the resolver directly (it's hard-coded to look up
// @fusion/pi-claude-cli), but proves the package.json parsing logic is
// robust when we refactor later.
describe("package.json edge cases (documentation)", () => {
it("fixture layout documents what a broken install looks like", () => {
const root = tempWorkspace("claude-cli-ext-");
// This fixture is not exercised by the current implementation but
// captures the shape we'd need to test if resolveClaudeCliExtension
// accepted a custom search path. Keeping it here so the next person
// refactoring has a template.
const pkgDir = join(root, "fake", "node_modules", "@fusion", "pi-claude-cli");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({ pi: { extensions: ["index.ts"] }, version: "0.0.0" }),
);
// No index.ts — would trigger missing-entry if we pointed the resolver here.
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,172 @@
/**
* Resolver for the vendored `@fusion/pi-claude-cli` pi extension.
*
* `@fusion/pi-claude-cli` is a workspace package at `packages/pi-claude-cli/`,
* a soft fork of rchern/pi-claude-cli (see that package's UPSTREAM.md). It
* ships its extension entry as raw `.ts` source — pi's loader compiles TS on
* the fly via jiti, so we just need to point pi at the right file.
*
* We deliberately do NOT auto-add "npm:@fusion/pi-claude-cli" to the user's
* ~/.fusion/agent/settings.json packages array. The package is resolved from
* this workspace at runtime and loaded explicitly only when
* GlobalSettings.useClaudeCli is true — this avoids polluting user-owned
* config files and lets us gate the extension on a UI toggle without
* settings.json churn.
*/
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const require_ = createRequire(import.meta.url);
/**
* Outcome of resolving the bundled @fusion/pi-claude-cli extension entry.
*
* - `"ok"`: the absolute path to the extension file was found — push it into
* the paths array passed to `discoverAndLoadExtensions`.
* - `"not-installed"`: the package isn't in node_modules (unusual — it's a
* hard dep, so this typically means a corrupted install).
* - `"missing-entry"`: the package is present but its package.json doesn't
* declare a pi.extensions entry, or the file it points to doesn't exist.
* Indicates a @fusion/pi-claude-cli version mismatch or a broken upstream release.
* - `"error"`: something unexpected — the reason is captured so the caller
* can surface it in the provider card.
*/
export type ClaudeCliExtensionResolution =
| { status: "ok"; path: string; packageVersion: string }
| { status: "not-installed" }
| { status: "missing-entry"; reason: string }
| { status: "error"; reason: string };
/**
* Resolve the absolute path to `@fusion/pi-claude-cli`'s pi extension entry file.
*
* Implementation notes:
* - `require.resolve("@fusion/pi-claude-cli/package.json")` is the canonical way
* to find a package's root from a dependent module without importing the
* package itself. It respects pnpm's strict layout.
* - We read pi.extensions[0] from the package.json rather than assuming
* a fixed filename; if upstream renames the entry we still work.
* - `createRequire(import.meta.url)` anchors resolution to this module's
* physical location, not `process.cwd()`, so the dep is found wherever
* `@runfusion/fusion` itself is installed.
*/
export function resolveClaudeCliExtension(): ClaudeCliExtensionResolution {
let pkgJsonPath: string;
try {
pkgJsonPath = require_.resolve("@fusion/pi-claude-cli/package.json");
} catch {
return { status: "not-installed" };
}
let pkgJson: { pi?: { extensions?: unknown }; version?: string };
try {
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as typeof pkgJson;
} catch (err) {
return {
status: "error",
reason: `Failed to read @fusion/pi-claude-cli package.json: ${err instanceof Error ? err.message : String(err)}`,
};
}
const extensions = pkgJson.pi?.extensions;
if (!Array.isArray(extensions) || extensions.length === 0) {
return {
status: "missing-entry",
reason: "@fusion/pi-claude-cli package.json has no pi.extensions array",
};
}
const rawEntry = extensions[0];
if (typeof rawEntry !== "string" || rawEntry.length === 0) {
return {
status: "missing-entry",
reason: "@fusion/pi-claude-cli pi.extensions[0] is not a valid path string",
};
}
const entryPath = resolve(dirname(pkgJsonPath), rawEntry);
if (!existsSync(entryPath)) {
return {
status: "missing-entry",
reason: `@fusion/pi-claude-cli extension file not found at ${entryPath}`,
};
}
return {
status: "ok",
path: entryPath,
packageVersion: pkgJson.version ?? "unknown",
};
}
/**
* Compute the paths to append to `discoverAndLoadExtensions`' configuredPaths
* based on the user's `useClaudeCli` setting.
*
* When the setting is off we return no paths at all — the bundled
* `@fusion/pi-claude-cli` sits idle in node_modules and contributes nothing
* to the running pi session. Flipping the toggle on requires a server
* restart to pick up the new extension (pi has no stable runtime-reload API
* for custom provider registrations). The dashboard toggle hook surfaces
* this in its status response.
*
* `warning` is populated when resolution fails (corrupted install, missing
* entry). Callers should log it but must not fail startup — the feature is
* optional.
*/
export function resolveClaudeCliExtensionPaths(globalSettings: {
useClaudeCli?: unknown;
}): { paths: string[]; warning?: string; resolution: ClaudeCliExtensionResolution | null } {
const enabled = globalSettings?.useClaudeCli === true;
if (!enabled) {
return { paths: [], resolution: null };
}
const resolution = resolveClaudeCliExtension();
switch (resolution.status) {
case "ok":
return { paths: [resolution.path], resolution };
case "not-installed":
return {
paths: [],
resolution,
warning:
"useClaudeCli is on but @fusion/pi-claude-cli is not installed in node_modules. Run `pnpm install`.",
};
case "missing-entry":
case "error":
return { paths: [], resolution, warning: resolution.reason };
}
}
/**
* Last-observed resolution cached per-process. Populated by the CLI bootstrap
* (serve/daemon/dashboard) immediately after calling
* `resolveClaudeCliExtensionPaths`, so HTTP endpoints like
* GET /api/providers/claude-cli/status can report the same view of the world
* that the extension loader saw without re-probing node_modules on every
* request.
*/
let cachedResolution: ClaudeCliExtensionResolution | null = null;
export function setCachedClaudeCliResolution(
resolution: ClaudeCliExtensionResolution | null,
): void {
cachedResolution = resolution;
}
export function getCachedClaudeCliResolution(): ClaudeCliExtensionResolution | null {
return cachedResolution;
}
/**
* Test helper: allow tests to point the resolver at a fake package.
* Call with `undefined` to restore the real resolver. Never used in prod.
*/
// Exported for use by tests — see claude-cli-extension.test.ts
export const _testInternals = {
moduleUrl: (): string => fileURLToPath(import.meta.url),
};

View File

@@ -42,6 +42,11 @@ import {
ensureClaudeSkillsForAllProjectsOnStartup,
maybeInstallClaudeSkillForNewProject,
} from "./claude-skills-runner.js";
import {
getCachedClaudeCliResolution,
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
@@ -387,8 +392,30 @@ export async function runDaemon(opts: DaemonOptions = {}) {
.filter((r) => r.enabled)
.map((r) => r.path);
const claudeCliPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveClaudeCliExtensionPaths(globalSettings);
setCachedClaudeCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] pi-claude-cli: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useClaudeCli setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedClaudeCliResolution(null);
return [];
}
})();
const extensionsResult = await discoverAndLoadExtensions(
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
[
...getEnabledPiExtensionPaths(cwd),
...packageExtensionPaths,
...claudeCliPaths,
],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
@@ -460,6 +487,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => {
if (!next) return;
void (async () => {

View File

@@ -25,6 +25,11 @@ import {
ensureClaudeSkillsForAllProjectsOnStartup,
maybeInstallClaudeSkillForNewProject,
} from "./claude-skills-runner.js";
import {
getCachedClaudeCliResolution,
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui.js";
// Re-export for backward compatibility with tests
@@ -759,9 +764,31 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
.filter((r) => r.enabled)
.map((r) => r.path);
const claudeCliPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveClaudeCliExtensionPaths(globalSettings);
setCachedClaudeCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] pi-claude-cli: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useClaudeCli setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedClaudeCliResolution(null);
return [];
}
})();
// Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved.
const extensionsResult = await discoverAndLoadExtensions(
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
[
...getEnabledPiExtensionPaths(cwd),
...packageExtensionPaths,
...claudeCliPaths,
],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
@@ -1000,6 +1027,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => {
if (!next) return;
void (async () => {
@@ -1208,6 +1246,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => {
if (!next) return;
void (async () => {

View File

@@ -46,6 +46,11 @@ import {
ensureClaudeSkillsForAllProjectsOnStartup,
maybeInstallClaudeSkillForNewProject,
} from "./claude-skills-runner.js";
import {
getCachedClaudeCliResolution,
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -441,8 +446,33 @@ export async function runServe(
.filter((r) => r.enabled)
.map((r) => r.path);
// Conditionally load the vendored pi-claude-cli extension so the user's
// "Anthropic — via Claude CLI" provider routing takes effect without
// requiring a manual `pi-claude-cli` install.
const claudeCliPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveClaudeCliExtensionPaths(globalSettings);
setCachedClaudeCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] pi-claude-cli: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useClaudeCli setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedClaudeCliResolution(null);
return [];
}
})();
const extensionsResult = await discoverAndLoadExtensions(
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
[
...getEnabledPiExtensionPaths(cwd),
...packageExtensionPaths,
...claudeCliPaths,
],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
@@ -621,6 +651,17 @@ export async function runServe(
// is configured. The runner logs its own outcome and swallows errors.
maybeInstallClaudeSkillForNewProject(path);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => {
if (!next) return; // Toggle-off leaves existing skill symlinks alone.
void (async () => {