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

@@ -0,0 +1,252 @@
import { describe, it, expect } from "vitest";
import {
TOOL_MAPPINGS,
CUSTOM_TOOLS_MCP_PREFIX,
mapClaudeToolNameToPi,
mapPiToolNameToClaude,
translateClaudeArgsToPi,
translatePiArgsToClaude,
isCustomToolName,
} from "../src/tool-mapping";
describe("tool-mapping", () => {
describe("TOOL_MAPPINGS", () => {
it("exports 6 tool mappings", () => {
expect(TOOL_MAPPINGS).toHaveLength(6);
});
});
describe("mapClaudeToolNameToPi", () => {
it("maps Read to read", () => {
expect(mapClaudeToolNameToPi("Read")).toBe("read");
});
it("maps Write to write", () => {
expect(mapClaudeToolNameToPi("Write")).toBe("write");
});
it("maps Edit to edit", () => {
expect(mapClaudeToolNameToPi("Edit")).toBe("edit");
});
it("maps Bash to bash", () => {
expect(mapClaudeToolNameToPi("Bash")).toBe("bash");
});
it("maps Grep to grep", () => {
expect(mapClaudeToolNameToPi("Grep")).toBe("grep");
});
it("maps Glob to find", () => {
expect(mapClaudeToolNameToPi("Glob")).toBe("find");
});
it("passes through unknown tool names unchanged", () => {
expect(mapClaudeToolNameToPi("UnknownTool")).toBe("UnknownTool");
});
it("is case-insensitive for Claude tool names", () => {
expect(mapClaudeToolNameToPi("read")).toBe("read");
expect(mapClaudeToolNameToPi("READ")).toBe("read");
});
});
describe("mapPiToolNameToClaude", () => {
it("maps read to Read", () => {
expect(mapPiToolNameToClaude("read")).toBe("Read");
});
it("maps write to Write", () => {
expect(mapPiToolNameToClaude("write")).toBe("Write");
});
it("maps edit to Edit", () => {
expect(mapPiToolNameToClaude("edit")).toBe("Edit");
});
it("maps bash to Bash", () => {
expect(mapPiToolNameToClaude("bash")).toBe("Bash");
});
it("maps grep to Grep", () => {
expect(mapPiToolNameToClaude("grep")).toBe("Grep");
});
it("maps find to Glob", () => {
expect(mapPiToolNameToClaude("find")).toBe("Glob");
});
it("maps glob to Glob (asymmetry: both find and glob map to Glob)", () => {
expect(mapPiToolNameToClaude("glob")).toBe("Glob");
});
it("passes through unknown tool names unchanged", () => {
expect(mapPiToolNameToClaude("unknownTool")).toBe("unknownTool");
});
});
describe("translateClaudeArgsToPi", () => {
it("renames file_path to path for Read", () => {
const result = translateClaudeArgsToPi("Read", {
file_path: "/foo",
offset: 10,
});
expect(result).toEqual({ path: "/foo", offset: 10 });
});
it("renames file_path to path for Write", () => {
const result = translateClaudeArgsToPi("Write", {
file_path: "/bar",
content: "hello",
});
expect(result).toEqual({ path: "/bar", content: "hello" });
});
it("renames file_path, old_string, new_string for Edit", () => {
const result = translateClaudeArgsToPi("Edit", {
file_path: "/f",
old_string: "a",
new_string: "b",
});
expect(result).toEqual({ path: "/f", oldText: "a", newText: "b" });
});
it("passes through Bash args unchanged (no renames)", () => {
const result = translateClaudeArgsToPi("Bash", { command: "ls" });
expect(result).toEqual({ command: "ls" });
});
it("renames head_limit to limit for Grep", () => {
const result = translateClaudeArgsToPi("Grep", {
pattern: "x",
head_limit: 5,
});
expect(result).toEqual({ pattern: "x", limit: 5 });
});
it("passes through Glob args unchanged (no renames)", () => {
const result = translateClaudeArgsToPi("Glob", { pattern: "*.ts" });
expect(result).toEqual({ pattern: "*.ts" });
});
it("passes through args for unknown tools unchanged", () => {
const result = translateClaudeArgsToPi("UnknownTool", {
foo: 1,
bar: "baz",
});
expect(result).toEqual({ foo: 1, bar: "baz" });
});
it("preserves unknown args alongside renamed args", () => {
const result = translateClaudeArgsToPi("Read", {
file_path: "/foo",
offset: 10,
limit: 50,
extra_arg: true,
});
expect(result).toEqual({
path: "/foo",
offset: 10,
limit: 50,
extra_arg: true,
});
});
});
describe("translatePiArgsToClaude", () => {
it("renames path to file_path for read", () => {
const result = translatePiArgsToClaude("read", { path: "/foo" });
expect(result).toEqual({ file_path: "/foo" });
});
it("renames path, oldText, newText for edit", () => {
const result = translatePiArgsToClaude("edit", {
path: "/f",
oldText: "a",
newText: "b",
});
expect(result).toEqual({
file_path: "/f",
old_string: "a",
new_string: "b",
});
});
it("renames limit to head_limit for grep", () => {
const result = translatePiArgsToClaude("grep", {
pattern: "x",
limit: 5,
});
expect(result).toEqual({ pattern: "x", head_limit: 5 });
});
it("passes through unknown args alongside renamed args", () => {
const result = translatePiArgsToClaude("read", {
path: "/foo",
offset: 10,
extra: "val",
});
expect(result).toEqual({ file_path: "/foo", offset: 10, extra: "val" });
});
it("passes through args for unknown tools unchanged", () => {
const result = translatePiArgsToClaude("unknownTool", { foo: 1 });
expect(result).toEqual({ foo: 1 });
});
});
describe("MCP prefix stripping", () => {
it("strips mcp__custom-tools__ prefix from myTool", () => {
expect(mapClaudeToolNameToPi("mcp__custom-tools__myTool")).toBe("myTool");
});
it("strips mcp__custom-tools__ prefix from deploy", () => {
expect(mapClaudeToolNameToPi("mcp__custom-tools__deploy")).toBe("deploy");
});
it("handles empty name after prefix", () => {
expect(mapClaudeToolNameToPi("mcp__custom-tools__")).toBe("");
});
it("does NOT strip other MCP server prefixes", () => {
expect(mapClaudeToolNameToPi("mcp__other-server__foo")).toBe(
"mcp__other-server__foo",
);
});
it("built-in mappings still work alongside MCP prefix stripping", () => {
expect(mapClaudeToolNameToPi("Read")).toBe("read");
expect(mapClaudeToolNameToPi("Glob")).toBe("find");
});
it("CUSTOM_TOOLS_MCP_PREFIX is the correct string", () => {
expect(CUSTOM_TOOLS_MCP_PREFIX).toBe("mcp__custom-tools__");
});
});
describe("isCustomToolName", () => {
it("returns true for custom tool names", () => {
expect(isCustomToolName("myTool")).toBe(true);
expect(isCustomToolName("deploy")).toBe(true);
});
it("returns false for all 6 built-in tool names", () => {
expect(isCustomToolName("read")).toBe(false);
expect(isCustomToolName("write")).toBe(false);
expect(isCustomToolName("edit")).toBe(false);
expect(isCustomToolName("bash")).toBe(false);
expect(isCustomToolName("grep")).toBe(false);
expect(isCustomToolName("find")).toBe(false);
});
});
describe("translateClaudeArgsToPi with MCP prefix", () => {
it("MCP-prefixed custom tool args pass through unchanged", () => {
const result = translateClaudeArgsToPi("mcp__custom-tools__myTool", {
foo: 1,
bar: "baz",
});
expect(result).toEqual({ foo: 1, bar: "baz" });
});
});
});