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:
172
packages/cli/src/commands/claude-cli-extension.ts
Normal file
172
packages/cli/src/commands/claude-cli-extension.ts
Normal 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),
|
||||
};
|
||||
Reference in New Issue
Block a user