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>
273 lines
7.1 KiB
TypeScript
273 lines
7.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Hoist mock references so they survive vi.mock hoisting
|
|
const mocks = vi.hoisted(() => ({
|
|
writeFileSync: vi.fn(),
|
|
tmpdir: vi.fn(() => "/tmp"),
|
|
}));
|
|
|
|
// Mock node:fs writeFileSync to avoid disk I/O
|
|
vi.mock("node:fs", () => ({
|
|
writeFileSync: mocks.writeFileSync,
|
|
}));
|
|
|
|
// Mock node:os tmpdir
|
|
vi.mock("node:os", () => ({
|
|
tmpdir: mocks.tmpdir,
|
|
}));
|
|
|
|
import { getCustomToolDefs, writeMcpConfig } from "../src/mcp-config";
|
|
import type { McpToolDef } from "../src/mcp-config";
|
|
|
|
describe("getCustomToolDefs", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("filters out all 6 built-in tools and returns only custom tools", () => {
|
|
const mockPi = {
|
|
getAllTools: vi.fn(() => [
|
|
{
|
|
name: "read",
|
|
description: "Read file",
|
|
parameters: { type: "object" },
|
|
},
|
|
{
|
|
name: "write",
|
|
description: "Write file",
|
|
parameters: { type: "object" },
|
|
},
|
|
{
|
|
name: "edit",
|
|
description: "Edit file",
|
|
parameters: { type: "object" },
|
|
},
|
|
{
|
|
name: "bash",
|
|
description: "Run bash",
|
|
parameters: { type: "object" },
|
|
},
|
|
{ name: "grep", description: "Search", parameters: { type: "object" } },
|
|
{
|
|
name: "find",
|
|
description: "Find files",
|
|
parameters: { type: "object" },
|
|
},
|
|
{
|
|
name: "search",
|
|
description: "Custom search tool",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { query: { type: "string" } },
|
|
},
|
|
},
|
|
{
|
|
name: "deploy",
|
|
description: "Deploy app",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { target: { type: "string" } },
|
|
},
|
|
},
|
|
]),
|
|
};
|
|
|
|
const result = getCustomToolDefs(mockPi);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].name).toBe("search");
|
|
expect(result[1].name).toBe("deploy");
|
|
});
|
|
|
|
it("returns empty array when all tools are built-in", () => {
|
|
const mockPi = {
|
|
getAllTools: vi.fn(() => [
|
|
{
|
|
name: "read",
|
|
description: "Read file",
|
|
parameters: { type: "object" },
|
|
},
|
|
{
|
|
name: "write",
|
|
description: "Write file",
|
|
parameters: { type: "object" },
|
|
},
|
|
{
|
|
name: "edit",
|
|
description: "Edit file",
|
|
parameters: { type: "object" },
|
|
},
|
|
{
|
|
name: "bash",
|
|
description: "Run bash",
|
|
parameters: { type: "object" },
|
|
},
|
|
{ name: "grep", description: "Search", parameters: { type: "object" } },
|
|
{
|
|
name: "find",
|
|
description: "Find files",
|
|
parameters: { type: "object" },
|
|
},
|
|
]),
|
|
};
|
|
|
|
const result = getCustomToolDefs(mockPi);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("includes custom tool with correct name, description, inputSchema from parameters", () => {
|
|
const customParams = {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Search query" },
|
|
limit: { type: "number" },
|
|
},
|
|
required: ["query"],
|
|
};
|
|
|
|
const mockPi = {
|
|
getAllTools: vi.fn(() => [
|
|
{
|
|
name: "custom_search",
|
|
description: "Search the codebase",
|
|
parameters: customParams,
|
|
},
|
|
]),
|
|
};
|
|
|
|
const result = getCustomToolDefs(mockPi);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe("custom_search");
|
|
expect(result[0].description).toBe("Search the codebase");
|
|
expect(result[0].inputSchema).toBe(customParams);
|
|
});
|
|
|
|
it("handles pi.getAllTools() returning empty array", () => {
|
|
const mockPi = {
|
|
getAllTools: vi.fn(() => []),
|
|
};
|
|
|
|
const result = getCustomToolDefs(mockPi);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array when pi.getAllTools() returns undefined", () => {
|
|
const mockPi = {
|
|
getAllTools: vi.fn(() => undefined),
|
|
};
|
|
|
|
const result = getCustomToolDefs(mockPi);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array when pi.getAllTools() returns null", () => {
|
|
const mockPi = {
|
|
getAllTools: vi.fn(() => null),
|
|
};
|
|
|
|
const result = getCustomToolDefs(mockPi);
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("writeMcpConfig", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.tmpdir.mockReturnValue("/tmp");
|
|
});
|
|
|
|
it("writes schema file to tmpdir with correct content (JSON array of tool defs)", () => {
|
|
const toolDefs: McpToolDef[] = [
|
|
{
|
|
name: "search",
|
|
description: "Search",
|
|
inputSchema: { type: "object" },
|
|
},
|
|
];
|
|
|
|
writeMcpConfig(toolDefs);
|
|
|
|
// First writeFileSync call is the schema file
|
|
const schemaCall = mocks.writeFileSync.mock.calls[0];
|
|
expect(schemaCall[0]).toMatch(/pi-claude-mcp-schemas/);
|
|
expect(JSON.parse(schemaCall[1])).toEqual(toolDefs);
|
|
});
|
|
|
|
it("writes config file to tmpdir with mcpServers.custom-tools entry", () => {
|
|
const toolDefs: McpToolDef[] = [
|
|
{
|
|
name: "search",
|
|
description: "Search",
|
|
inputSchema: { type: "object" },
|
|
},
|
|
];
|
|
|
|
writeMcpConfig(toolDefs);
|
|
|
|
// Second writeFileSync call is the config file
|
|
const configCall = mocks.writeFileSync.mock.calls[1];
|
|
const config = JSON.parse(configCall[1]);
|
|
expect(config).toHaveProperty("mcpServers");
|
|
expect(config.mcpServers).toHaveProperty("custom-tools");
|
|
});
|
|
|
|
it("config uses 'command': 'node' format (not 'type': 'http')", () => {
|
|
const toolDefs: McpToolDef[] = [
|
|
{
|
|
name: "search",
|
|
description: "Search",
|
|
inputSchema: { type: "object" },
|
|
},
|
|
];
|
|
|
|
writeMcpConfig(toolDefs);
|
|
|
|
const configCall = mocks.writeFileSync.mock.calls[1];
|
|
const config = JSON.parse(configCall[1]);
|
|
const server = config.mcpServers["custom-tools"];
|
|
|
|
expect(server.command).toBe("node");
|
|
expect(server).not.toHaveProperty("type");
|
|
});
|
|
|
|
it("config args include path to mcp-schema-server.cjs and schema file path", () => {
|
|
const toolDefs: McpToolDef[] = [
|
|
{
|
|
name: "search",
|
|
description: "Search",
|
|
inputSchema: { type: "object" },
|
|
},
|
|
];
|
|
|
|
writeMcpConfig(toolDefs);
|
|
|
|
const configCall = mocks.writeFileSync.mock.calls[1];
|
|
const config = JSON.parse(configCall[1]);
|
|
const server = config.mcpServers["custom-tools"];
|
|
|
|
expect(server.args).toHaveLength(2);
|
|
// First arg should be the server .cjs path (normalize separators for Windows)
|
|
expect(server.args[0].replace(/\\/g, "/")).toContain(
|
|
"mcp-schema-server.cjs",
|
|
);
|
|
// Second arg should be the schema file path
|
|
expect(server.args[1]).toMatch(/pi-claude-mcp-schemas/);
|
|
});
|
|
|
|
it("returns the config file path", () => {
|
|
const toolDefs: McpToolDef[] = [
|
|
{
|
|
name: "search",
|
|
description: "Search",
|
|
inputSchema: { type: "object" },
|
|
},
|
|
];
|
|
|
|
const result = writeMcpConfig(toolDefs);
|
|
|
|
expect(result).toMatch(/pi-claude-mcp-config/);
|
|
expect(result).toMatch(/\.json$/);
|
|
});
|
|
});
|