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:
5
.changeset/auto-install-fusion-skill-init.md
Normal file
5
.changeset/auto-install-fusion-skill-init.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Automatically install the bundled Fusion skill into supported agent home directories during `fn init` (`~/.claude/skills/fusion`, `~/.codex/skills/fusion`, and `~/.gemini/skills/fusion`) when missing. Existing installs are preserved, and per-target filesystem errors now warn without failing project initialization.
|
||||||
@@ -36,6 +36,16 @@ fn init
|
|||||||
fn init --name my-project --path /absolute/path/to/project
|
fn init --name my-project --path /absolute/path/to/project
|
||||||
```
|
```
|
||||||
|
|
||||||
|
During fresh initialization, Fusion also installs the bundled `fusion` skill into supported local agent homes when the target skill does not already exist:
|
||||||
|
|
||||||
|
- `~/.claude/skills/fusion`
|
||||||
|
- `~/.codex/skills/fusion`
|
||||||
|
- `~/.gemini/skills/fusion`
|
||||||
|
|
||||||
|
`fn init` is non-destructive for these installs:
|
||||||
|
- Existing `fusion` skill directories are preserved (not overwritten).
|
||||||
|
- Per-target filesystem/permission failures are reported as warnings and do not fail project initialization.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `fn dashboard`
|
## `fn dashboard`
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ Then verify install:
|
|||||||
fn --help
|
fn --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Initialize a Project
|
||||||
|
|
||||||
|
In each repository you want Fusion to manage, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn init
|
||||||
|
```
|
||||||
|
|
||||||
|
On fresh init, Fusion also installs its bundled `fusion` skill into supported agent homes (`~/.claude/skills/fusion`, `~/.codex/skills/fusion`, `~/.gemini/skills/fusion`) when those targets are missing. Existing installs are left untouched.
|
||||||
|
|
||||||
## First Run and Onboarding
|
## First Run and Onboarding
|
||||||
|
|
||||||
Start the dashboard:
|
Start the dashboard:
|
||||||
|
|||||||
@@ -33,9 +33,17 @@ function tempDir(prefix: string): string {
|
|||||||
|
|
||||||
describe("init command", () => {
|
describe("init command", () => {
|
||||||
let tempProjectDir: string;
|
let tempProjectDir: string;
|
||||||
|
let tempHomeDir: string;
|
||||||
|
let originalHome: string | undefined;
|
||||||
|
let originalUserProfile: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempProjectDir = tempDir("fn-init-test-");
|
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);
|
mockCentralInit.mockResolvedValue(undefined);
|
||||||
mockCentralClose.mockResolvedValue(undefined);
|
mockCentralClose.mockResolvedValue(undefined);
|
||||||
mockGetProjectByPath.mockResolvedValue(undefined);
|
mockGetProjectByPath.mockResolvedValue(undefined);
|
||||||
@@ -48,9 +56,23 @@ describe("init command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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)) {
|
if (existsSync(tempProjectDir)) {
|
||||||
rmSync(tempProjectDir, { recursive: true, force: true });
|
rmSync(tempProjectDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
if (existsSync(tempHomeDir)) {
|
||||||
|
rmSync(tempHomeDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create .fusion/ directory when initializing", async () => {
|
it("should create .fusion/ directory when initializing", async () => {
|
||||||
@@ -164,6 +186,55 @@ describe("init command", () => {
|
|||||||
expect(piMatches).toHaveLength(1);
|
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 () => {
|
it("should add .pi when .fusion is already ignored", async () => {
|
||||||
const gitignorePath = join(tempProjectDir, ".gitignore");
|
const gitignorePath = join(tempProjectDir, ".gitignore");
|
||||||
writeFileSync(gitignorePath, "node_modules\n.fusion\n");
|
writeFileSync(gitignorePath, "node_modules\n.fusion\n");
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { promisify } from "node:util";
|
|||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
|
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
|
||||||
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
||||||
|
import {
|
||||||
|
installBundledFusionSkill,
|
||||||
|
type SkillInstallResult,
|
||||||
|
} from "./skill-installation.js";
|
||||||
|
|
||||||
/** Options for the init command */
|
/** Options for the init command */
|
||||||
export interface InitOptions {
|
export interface InitOptions {
|
||||||
@@ -89,6 +93,9 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
|||||||
console.log(` ✓ Created fusion.db`);
|
console.log(` ✓ Created fusion.db`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bundledSkillInstall = installBundledFusionSkill();
|
||||||
|
logBundledSkillInstallResults(bundledSkillInstall.results);
|
||||||
|
|
||||||
// Register in central database
|
// Register in central database
|
||||||
const central = new CentralCore();
|
const central = new CentralCore();
|
||||||
await central.init();
|
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(` ⚠ qmd not found; memory search will use local file fallback`);
|
||||||
console.log(` Install qmd for indexed retrieval: ${QMD_INSTALL_COMMAND}`);
|
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