feat(FN-2370): merge fusion/fn-2370 (auto-resolved)

- test(FN-2370): complete Step 3 — align qa-check template expectation
- test(FN-2370): complete Step 2 — add regression coverage for addComment diagnostics
- test(FN-2370): complete Step 2 — cover addComment warning regressions
- feat(FN-2370): complete Step 1 — log addComment best-effort failures
- feat(FN-2369): merge fusion/fn-2369
- feat(prompts): require lint alongside tests and typecheck in agent instructions
- perf(test): parallelize harder — unlock worker count, split build-output, bump workspace concurrency
- fix(core): recognize legacy kb-* backups and canonicalize .kb/backups settings
- refactor: eliminate remaining 15 any warnings and ratchet rule to error
- refactor: eliminate ~400 no-explicit-any warnings across the workspace
- feat(core): add getErrorMessage helper for narrowing unknown errors
- refactor: fix and tighten mechanical lint rules
- chore(eslint): fix pre-existing errors surfaced by wider .cjs match
- chore(eslint): promote @typescript-eslint/no-unused-vars from warn to error
- refactor(dashboard,desktop,engine): remove unused imports, props, and locals
- refactor(core): remove unused imports, helpers, and dead migration constant
- refactor(cli): remove unused imports and variables
- refactor: adapt resource loader and tool wiring to pi-coding-agent 0.70
- fix: adapt to AgentState.error → errorMessage rename
- refactor: migrate @sinclair/typebox imports to typebox 1.x
- refactor: migrate to ModelRegistry.create factory
- chore: bump pi-coding-agent + pi-ai to 0.70.0
- refactor: remove legacy kb compatibility
- feat: add "Anthropic — via Claude CLI" as a first-class provider
- test(FN-2358): harden clean-worktree CI verification tests
- fix(FN-2352): add structured terminal websocket diagnostics
- fix: use live merge-base for task diff scope
- feat: backfill Claude skills when useClaudeCli toggle flips on
- fix: prevent nested .fusion/.fusion dir from PluginStore path bug
This commit is contained in:
Fusion
2026-04-24 01:39:26 -07:00
committed by gsxdsm
parent 4f9f36fe75
commit 7c1a1c36cc
25 changed files with 459 additions and 283 deletions

View File

@@ -1,4 +1,7 @@
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,
@@ -19,16 +22,40 @@ describe("resolveClaudeCliExtension", () => {
});
describe("resolveClaudeCliExtensionPaths", () => {
// Post-redesign (2026-04-23): the extension loads unconditionally so the
// setting only gates the `/api/models` filter, not extension registration.
// This function now takes no arguments and always returns the resolved
// workspace path — cleaner contract, no settings coupling.
it("always returns the resolved workspace path when the package is installed", () => {
const result = 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");
expect(result.warning).toBeUndefined();
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([]);
});
});
@@ -43,3 +70,25 @@ describe("cached resolution roundtrip", () => {
});
});
// 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

@@ -103,27 +103,28 @@ export function resolveClaudeCliExtension(): ClaudeCliExtensionResolution {
}
/**
* Compute the paths to append to `discoverAndLoadExtensions`' configuredPaths.
* Compute the paths to append to `discoverAndLoadExtensions`' configuredPaths
* based on the user's `useClaudeCli` setting.
*
* The extension is loaded unconditionally — the provider it registers lives
* under a distinct id (`"pi-claude-cli"`, see the vendored package's
* index.ts) so coexistence with direct Anthropic auth is safe. When the
* user flips `useClaudeCli` off, the provider stays registered; the dashboard
* simply hides its models from the picker via the `/api/models` filter.
* 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.
*
* This "always load" choice means toggling the setting has immediate effect
* — no Fusion restart required. If `@fusion/pi-claude-cli` itself is missing
* or broken (unusual — it's a hard workspace dep), we emit a warning and
* return no paths; pi will continue without CLI-routed models.
*
* `warning` is populated when resolution fails. Callers should log it but
* must not fail startup.
* `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(): {
paths: string[];
warning?: string;
resolution: ClaudeCliExtensionResolution;
} {
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":
@@ -133,7 +134,7 @@ export function resolveClaudeCliExtensionPaths(): {
paths: [],
resolution,
warning:
"@fusion/pi-claude-cli is not installed in node_modules. Run `pnpm install`.",
"useClaudeCli is on but @fusion/pi-claude-cli is not installed in node_modules. Run `pnpm install`.",
};
case "missing-entry":
case "error":

View File

@@ -393,16 +393,22 @@ export async function runDaemon(opts: DaemonOptions = {}) {
.filter((r) => r.enabled)
.map((r) => r.path);
// Always load the vendored pi-claude-cli extension — see comment in
// serve.ts for rationale. The `useClaudeCli` setting only affects the
// /api/models filter, not extension registration.
const claudeCliPaths = (() => {
const result = resolveClaudeCliExtensionPaths();
setCachedClaudeCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] pi-claude-cli: ${result.warning}`);
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 [];
}
return result.paths;
})();
const extensionsResult = await discoverAndLoadExtensions(

View File

@@ -768,14 +768,22 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
.filter((r) => r.enabled)
.map((r) => r.path);
// Always load the vendored pi-claude-cli extension — see serve.ts.
const claudeCliPaths = (() => {
const result = resolveClaudeCliExtensionPaths();
setCachedClaudeCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] pi-claude-cli: ${result.warning}`);
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 [];
}
return result.paths;
})();
// Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved.

View File

@@ -447,19 +447,25 @@ export async function runServe(
.filter((r) => r.enabled)
.map((r) => r.path);
// Always load the vendored pi-claude-cli extension. It registers under
// a distinct provider id ("pi-claude-cli") so it coexists with direct
// Anthropic auth. The `useClaudeCli` setting only controls whether the
// dashboard shows those models in the picker — the extension itself is
// a no-op when the `claude` binary is missing (it catches and logs
// internally, see packages/pi-claude-cli/index.ts:106).
const claudeCliPaths = (() => {
const result = resolveClaudeCliExtensionPaths();
setCachedClaudeCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] pi-claude-cli: ${result.warning}`);
// 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 [];
}
return result.paths;
})();
const extensionsResult = await discoverAndLoadExtensions(