feat(FN-2393): auto-install bundled Fusion skill during init

- Add reusable skill-installation helper that targets Claude, Codex, and Gemini skill homes
- Wire bundled skill installation into fn init with per-client installed/skipped/warning logging
- Add init command tests for install success, preserving existing installs, and non-fatal warning behavior
- Document the new init behavior in CLI/getting-started docs and add a patch changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-24 04:35:05 -07:00
committed by gsxdsm
parent d0c82714e6
commit c105cfaff1
6 changed files with 216 additions and 0 deletions

View File

@@ -33,9 +33,17 @@ function tempDir(prefix: string): string {
describe("init command", () => {
let tempProjectDir: string;
let tempHomeDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
beforeEach(() => {
tempProjectDir = tempDir("fn-init-test-");
tempHomeDir = tempDir("fn-init-home-");
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
process.env.HOME = tempHomeDir;
process.env.USERPROFILE = tempHomeDir;
mockCentralInit.mockResolvedValue(undefined);
mockCentralClose.mockResolvedValue(undefined);
mockGetProjectByPath.mockResolvedValue(undefined);
@@ -48,9 +56,23 @@ describe("init command", () => {
});
afterEach(() => {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = originalUserProfile;
}
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true });
}
if (existsSync(tempHomeDir)) {
rmSync(tempHomeDir, { recursive: true, force: true });
}
});
it("should create .fusion/ directory when initializing", async () => {
@@ -164,6 +186,55 @@ describe("init command", () => {
expect(piMatches).toHaveLength(1);
});
it("installs the bundled Fusion skill into Claude, Codex, and Gemini homes", async () => {
await runInit({ path: tempProjectDir });
const skillTargets = [
join(tempHomeDir, ".claude", "skills", "fusion"),
join(tempHomeDir, ".codex", "skills", "fusion"),
join(tempHomeDir, ".gemini", "skills", "fusion"),
];
for (const target of skillTargets) {
expect(existsSync(join(target, "SKILL.md"))).toBe(true);
expect(existsSync(join(target, "references", "extension-tools.md"))).toBe(true);
expect(existsSync(join(target, "workflows", "task-management.md"))).toBe(true);
}
});
it("preserves existing Fusion skill directories instead of overwriting", async () => {
const existingSkillDir = join(tempHomeDir, ".claude", "skills", "fusion");
mkdirSync(existingSkillDir, { recursive: true });
writeFileSync(join(existingSkillDir, "SKILL.md"), "custom skill content\n");
await runInit({ path: tempProjectDir });
expect(readFileSync(join(existingSkillDir, "SKILL.md"), "utf-8")).toBe("custom skill content\n");
expect(existsSync(join(existingSkillDir, "references", "extension-tools.md"))).toBe(false);
});
it("logs skill install warnings without aborting init", async () => {
const blockedClaudePath = join(tempHomeDir, ".claude");
writeFileSync(blockedClaudePath, "blocked");
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (...args: unknown[]) => {
warnings.push(args.join(" "));
};
try {
await runInit({ path: tempProjectDir });
} finally {
console.warn = originalWarn;
}
expect(existsSync(join(tempProjectDir, ".fusion", "fusion.db"))).toBe(true);
expect(warnings.some((warning) => warning.includes("Could not install bundled Fusion skill for Claude"))).toBe(true);
expect(existsSync(join(tempHomeDir, ".codex", "skills", "fusion", "SKILL.md"))).toBe(true);
expect(existsSync(join(tempHomeDir, ".gemini", "skills", "fusion", "SKILL.md"))).toBe(true);
});
it("should add .pi when .fusion is already ignored", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.fusion\n");

View File

@@ -15,6 +15,10 @@ import { promisify } from "node:util";
const execAsync = promisify(exec);
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
import {
installBundledFusionSkill,
type SkillInstallResult,
} from "./skill-installation.js";
/** Options for the init command */
export interface InitOptions {
@@ -89,6 +93,9 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
console.log(` ✓ Created fusion.db`);
}
const bundledSkillInstall = installBundledFusionSkill();
logBundledSkillInstallResults(bundledSkillInstall.results);
// Register in central database
const central = new CentralCore();
await central.init();
@@ -210,3 +217,22 @@ async function warnIfQmdMissing(): Promise<void> {
console.log(` ⚠ qmd not found; memory search will use local file fallback`);
console.log(` Install qmd for indexed retrieval: ${QMD_INSTALL_COMMAND}`);
}
function logBundledSkillInstallResults(results: SkillInstallResult[]): void {
for (const result of results) {
const clientLabel = result.client[0].toUpperCase() + result.client.slice(1);
if (result.outcome === "installed") {
console.log(` ✓ Installed bundled Fusion skill for ${clientLabel}: ${result.targetDir}`);
continue;
}
if (result.outcome === "skipped") {
console.log(` ✓ Existing ${clientLabel} Fusion skill preserved: ${result.targetDir}`);
continue;
}
console.warn(
` ⚠ Could not install bundled Fusion skill for ${clientLabel}: ${result.reason ?? "unknown error"}`,
);
}
}

View File

@@ -0,0 +1,94 @@
import { cpSync, existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const FUSION_SKILL_NAME = "fusion";
export type SupportedSkillClient = "claude" | "codex" | "gemini";
export interface SkillInstallTarget {
client: SupportedSkillClient;
targetDir: string;
}
export type SkillInstallOutcome = "installed" | "skipped" | "warning";
export interface SkillInstallResult {
client: SupportedSkillClient;
targetDir: string;
outcome: SkillInstallOutcome;
reason?: string;
}
export interface InstallBundledFusionSkillResult {
sourceDir: string | null;
results: SkillInstallResult[];
}
export function getSupportedSkillInstallTargets(
homeDir = process.env.HOME || process.env.USERPROFILE || homedir(),
): SkillInstallTarget[] {
return [
{ client: "claude", targetDir: join(homeDir, ".claude", "skills", FUSION_SKILL_NAME) },
{ client: "codex", targetDir: join(homeDir, ".codex", "skills", FUSION_SKILL_NAME) },
{ client: "gemini", targetDir: join(homeDir, ".gemini", "skills", FUSION_SKILL_NAME) },
];
}
export function resolveBundledFusionSkillSource(): string | null {
const here = fileURLToPath(import.meta.url);
const source = resolve(dirname(here), "..", "..", "skill", FUSION_SKILL_NAME);
return existsSync(source) ? source : null;
}
export function installBundledFusionSkill(options: {
homeDir?: string;
sourceDir?: string | null;
} = {}): InstallBundledFusionSkillResult {
const sourceDir = options.sourceDir ?? resolveBundledFusionSkillSource();
const targets = getSupportedSkillInstallTargets(options.homeDir);
if (!sourceDir) {
return {
sourceDir,
results: targets.map((target) => ({
client: target.client,
targetDir: target.targetDir,
outcome: "warning" as const,
reason: "bundled Fusion skill source directory not found",
})),
};
}
const results = targets.map<SkillInstallResult>((target) => {
try {
if (existsSync(target.targetDir)) {
return {
client: target.client,
targetDir: target.targetDir,
outcome: "skipped",
reason: "existing install preserved",
};
}
mkdirSync(dirname(target.targetDir), { recursive: true });
cpSync(sourceDir, target.targetDir, { recursive: true });
return {
client: target.client,
targetDir: target.targetDir,
outcome: "installed",
};
} catch (error) {
return {
client: target.client,
targetDir: target.targetDir,
outcome: "warning",
reason: error instanceof Error ? error.message : String(error),
};
}
});
return { sourceDir, results };
}