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:
@@ -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");
|
||||
|
||||
@@ -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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
94
packages/cli/src/commands/skill-installation.ts
Normal file
94
packages/cli/src/commands/skill-installation.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user