FN-8961: add computer-use skill and version-matched guide
Ship the computer-use skill across bundled clients and surface its matching guide through the CLI. - add the computer-use skill, computer command descriptor, and `fn skills get computer-use` guide - reconcile all shipped Claude skills at project and startup entry points - cover both skills' reconciliation outcomes and every required call site Files changed: .changeset/fn-8961-computer-use-skill.md | 7 + docs/cli-reference.md | 6 +- docs/computer-use.md | 8 + packages/cli/skill/computer-use/SKILL.md | 31 +++ packages/cli/src/__tests__/package-config.test.ts | 20 ++ packages/cli/src/bin.ts | 14 +- .../__tests__/claude-skills-callsites.test.ts | 64 ++++++ .../src/commands/__tests__/claude-skills.test.ts | 221 ++++++++------------- .../__tests__/computer-surface-descriptor.test.ts | 108 ++++++++++ .../commands/__tests__/computer-use-guide.test.ts | 36 ++++ .../commands/__tests__/computer-use-skill.test.ts | 20 ++ packages/cli/src/commands/__tests__/daemon.test.ts | 36 +++- .../__tests__/dashboard-claude-skills.test.ts | 19 ++ packages/cli/src/commands/__tests__/init.test.ts | 48 ++++- .../cli/src/commands/__tests__/project.test.ts | 38 ++++ packages/cli/src/commands/__tests__/serve.test.ts | 36 +++- .../commands/__tests__/skill-installation.test.ts | 12 ++ .../cli/src/commands/__tests__/skills-get.test.ts | 67 ++++++ packages/cli/src/commands/claude-skills-runner.ts | 55 ++--- packages/cli/src/commands/claude-skills.ts | 47 +++-- packages/cli/src/commands/computer.ts | 6 +- packages/cli/src/commands/computer/contract.ts | 52 +++++ packages/cli/src/commands/computer/guide.ts | 62 ++++++ packages/cli/src/commands/init.ts | 4 +- packages/cli/src/commands/skill-installation.ts | 45 +++-- packages/cli/src/commands/skills.ts | 24 +++ 26 files changed, 853 insertions(+), 233 deletions(-) Fusion-Task-Id: FN-8961 Fusion-Task-Lineage: b6baaebb-bf72-4991-8239-eb9bcf9cbcf1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8961-computer-use-skill.md
Normal file
7
.changeset/fn-8961-computer-use-skill.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a version-matched computer-use agent skill and guide command.
|
||||
category: feature
|
||||
dev: Adds `fn skills get computer-use` and installs both shipped skills for supported clients.
|
||||
@@ -1353,9 +1353,13 @@ Browse and install agent skills from [skills.sh](https://skills.sh).
|
||||
```bash
|
||||
fn skills search <query> [--limit <n>]
|
||||
fn skills install <owner/repo> [--skill <name>]
|
||||
fn skills get <skill-name>
|
||||
```
|
||||
|
||||
Subcommands: `search`, `install`.
|
||||
`fn skills get computer-use` prints Fusion's in-process, version-matched computer-use guide. Unknown or missing names exit non-zero and list known built-in skills.
|
||||
|
||||
|
||||
Subcommands: `search`, `install`, `get`.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
|
||||
@@ -154,3 +154,11 @@ No user timeout flag is provided. Default seam timeouts are: permission probe 5
|
||||
This surface was informed by [Orca](https://github.com/stablyai/orca) and its [computer-use CLI documentation](https://www.onorca.dev/docs/cli/computer-use). Orca uses two layers: a native-backed `orca computer` CLI and a thin skill that loads a version-matched guide. Fusion follows the CLI-plus-skill separation; the skill is deliberately out of scope for this command and is delivered separately.
|
||||
|
||||
Fusion deliberately differs by using macOS OS built-ins instead of native helpers; publishing non-mutating preflight permission checks, an `unknown` status, and the outcome matrix; documenting durable on-disk cross-process snapshots with identity-verified window/locator replay; distinguishing stale, missing, and unresolvable indexes; publishing deterministic failure precedence; and using a versioned envelope with a fixed append-only error enum. `paste-text` and `perform-secondary-action` are not implemented in this release; they remain absent from capabilities rather than being stubs.
|
||||
|
||||
## Use it from an agent
|
||||
|
||||
Fusion ships a thin `computer-use` skill alongside the Fusion skill. When Claude-compatible skill installation is configured, both are reconciled into project `.claude/skills/` directories; `fn init` also copies both into supported home skill directories.
|
||||
|
||||
The discovery stub intentionally contains no command reference. An agent resolves one `fn` executable for its session and runs `fn skills get computer-use` before automation. That command renders its complete guide in-process from the same binary's command-surface descriptor and reports the same package version as `fn --version`.
|
||||
|
||||
This is an anti-drift contract: dispatch keys, parser flag literals, required/mutually-exclusive validation, and emitted error codes are independently checked against the descriptor; the rendered guide then includes every command, flag, and error code without truncation. The delivered parser still does not reject unknown flags or invalid enum choices, and a flag literal's source presence does not itself prove a handler honors it.
|
||||
|
||||
31
packages/cli/skill/computer-use/SKILL.md
Normal file
31
packages/cli/skill/computer-use/SKILL.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: computer-use
|
||||
description: Control a desktop app, click a button in an app window, read what is on screen in an app, automate a GUI, drive a native app, or fill a form in a desktop app.
|
||||
---
|
||||
|
||||
<!--
|
||||
FNXC:ComputerUseSkill 2026-08-11-07:19:
|
||||
This discovery stub must never gain a computer command or flag list. The binary owns its matching
|
||||
reference through `fn skills get computer-use`; computer-use-skill.test.ts enforces this boundary.
|
||||
-->
|
||||
|
||||
## When to engage
|
||||
Use this skill for desktop GUI automation, reading native app state, clicking controls, or filling desktop forms.
|
||||
|
||||
## Resolve the CLI for this session
|
||||
Resolve exactly one executable in this order: an explicitly exported `FUSION_CLI_BIN`, then `fn` on PATH, then the dev-checkout entry point. Reuse that executable for every command in this session. If resolution fails, report the error and stop; do not fall through to a different binary that could target another build.
|
||||
|
||||
## Load the full guide before running commands
|
||||
Using the resolved executable, run `fn skills get computer-use` before any computer command. It prints the complete guide for the exact binary that will execute work.
|
||||
|
||||
## Keep this stub thin
|
||||
This stub deliberately lists no subcommands or flags, so it can never drift from the binary that will actually run your commands.
|
||||
|
||||
## Older-binary fallback
|
||||
If that binary does not know `skills get`, only inspect these read-only capabilities, then ask the user to update Fusion rather than guessing a command surface:
|
||||
|
||||
- `fn computer capabilities --json`
|
||||
- `fn computer permissions --json`
|
||||
- `fn computer list-apps --json`
|
||||
|
||||
macOS is first-class. On other platforms capabilities report `supported: false` and actions fail with `UNSUPPORTED_PLATFORM`; stop instead of retrying.
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { builtinModules } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parse } from "yaml";
|
||||
import { applyPrepackTransform } from "../../scripts/prepare-publish-manifest.mjs";
|
||||
|
||||
@@ -513,3 +514,22 @@ describe("Workflow YAML validity", () => {
|
||||
expect(parsed.name).toBe("Version & Release");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shipped agent skills", () => {
|
||||
it("keeps computer-use in the published skill tree", () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-07:19: package files globs, manifest transform, and the
|
||||
* actual npm pack file list together prevent a source-only skill from being mistaken for shipped. */
|
||||
const cli = loadPackageJson("cli");
|
||||
expect(cli.pi.skills).toContain("./skill");
|
||||
expect(cli.files).toContain("skill/**");
|
||||
expect(applyPrepackTransform(cli).files).toContain("skill/**");
|
||||
const packageDir = join(workspaceRoot, "packages", "cli");
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
|
||||
cwd: packageDir,
|
||||
encoding: "utf8",
|
||||
})) as Array<{ files: Array<{ path: string }> }>;
|
||||
const packedPaths = new Set(packed[0]!.files.map((file) => file.path));
|
||||
expect(packedPaths).toContain("skill/fusion/SKILL.md");
|
||||
expect(packedPaths).toContain("skill/computer-use/SKILL.md");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ async function loadCommandHandlers() {
|
||||
const { runPluginCreate, runPluginNew } = await import("./commands/plugin-scaffold.js");
|
||||
const { runPluginDev } = await import("./commands/plugin-dev.js");
|
||||
const { runPluginPublish } = await import("./commands/plugin-publish.js");
|
||||
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
|
||||
const { runSkillsSearch, runSkillsInstall, runSkillsGet } = await import("./commands/skills.js");
|
||||
const { runComputer } = await import("./commands/computer.js");
|
||||
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
|
||||
const { runExperimentFinalize } = await import("./commands/experiment-finalize.js");
|
||||
@@ -288,6 +288,7 @@ async function loadCommandHandlers() {
|
||||
runPluginPublish,
|
||||
runSkillsSearch,
|
||||
runSkillsInstall,
|
||||
runSkillsGet,
|
||||
runComputer,
|
||||
runResearchCreate,
|
||||
runResearchList,
|
||||
@@ -497,6 +498,7 @@ PR:
|
||||
fn skills install <owner/repo> Install skills from a source
|
||||
fn skills install <owner/repo> --skill <name>
|
||||
Install a specific skill
|
||||
fn skills get <skill-name> Print a built-in version-matched guide
|
||||
fn computer <subcommand> [--json] Inspect and automate supported desktop applications
|
||||
See fn computer --help for snapshot → act → snapshot commands
|
||||
|
||||
@@ -846,6 +848,7 @@ async function main() {
|
||||
runPluginPublish,
|
||||
runSkillsSearch,
|
||||
runSkillsInstall,
|
||||
runSkillsGet,
|
||||
runComputer,
|
||||
runResearchCreate,
|
||||
runResearchList,
|
||||
@@ -2314,6 +2317,7 @@ async function main() {
|
||||
console.log(" fn skills install <owner/repo> Install skills from a source");
|
||||
console.log(" fn skills install <owner/repo> --skill <name>");
|
||||
console.log(" Install a specific skill");
|
||||
console.log(" fn skills get <skill-name> Print a built-in version-matched guide");
|
||||
console.log("\nExamples:");
|
||||
console.log(" fn skills search react");
|
||||
console.log(" fn skills search firebase --limit 5");
|
||||
@@ -2365,8 +2369,14 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
if (subcommand === "get") {
|
||||
const exitCode = await runSkillsGet(args.slice(2));
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
break;
|
||||
}
|
||||
|
||||
console.error(`Unknown subcommand: skills ${subcommand}`);
|
||||
console.log("Try: fn skills search | install");
|
||||
console.log("Try: fn skills search | install | get");
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { tempWorkspace } from "@fusion/test-utils";
|
||||
import { SHIPPED_SKILL_NAMES } from "../claude-skills.js";
|
||||
import { ensureClaudeSkillsForAllProjectsOnStartup } from "../claude-skills-runner.js";
|
||||
const commands = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
describe("Claude skill call sites", () => {
|
||||
it("C7d reconciles both skills for every project and skips all writes when disabled", () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-12:59: C7d must exercise the runner as well as pin host
|
||||
* text: static call-site counts cannot prove multi-project reconciliation or the disabled
|
||||
* pi-claude-cli no-write contract. Explicit sources keep this test independent of packaging. */
|
||||
const root = tempWorkspace("fusion-claude-skills-callsites-");
|
||||
const projects = ["first", "second"].map((name, index) => {
|
||||
const path = join(root, name);
|
||||
mkdirSync(path, { recursive: true });
|
||||
return { id: String(index), name, path };
|
||||
});
|
||||
const sources = Object.fromEntries(SHIPPED_SKILL_NAMES.map((skillName) => {
|
||||
const source = join(root, "sources", skillName);
|
||||
mkdirSync(source, { recursive: true });
|
||||
writeFileSync(join(source, "SKILL.md"), `---\nname: ${skillName}\n---\n`);
|
||||
return [skillName, source];
|
||||
}));
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
try {
|
||||
const installed = ensureClaudeSkillsForAllProjectsOnStartup(projects, { enabled: true, sources });
|
||||
expect(installed).toHaveLength(projects.length * SHIPPED_SKILL_NAMES.length);
|
||||
expect(installed.map((result) => result.outcome)).toEqual(["installed", "installed", "installed", "installed"]);
|
||||
for (const project of projects) {
|
||||
for (const skillName of SHIPPED_SKILL_NAMES) {
|
||||
expect(existsSync(join(project.path, ".claude", "skills", skillName, "SKILL.md"))).toBe(true);
|
||||
}
|
||||
}
|
||||
const disabledRoot = join(root, "disabled");
|
||||
mkdirSync(disabledRoot, { recursive: true });
|
||||
const disabled = ensureClaudeSkillsForAllProjectsOnStartup([{ id: "disabled", name: "disabled", path: disabledRoot }], { enabled: false, sources });
|
||||
expect(disabled.map((result) => result.outcome)).toEqual(["skipped", "skipped"]);
|
||||
expect(existsSync(join(disabledRoot, ".claude"))).toBe(false);
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("pins every startup host to the generalized reconciliation runner", () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-07:19: These seven host calls are pinned separately from
|
||||
* runner behavior so a dropped startup reconciliation site cannot silently lose computer-use. */
|
||||
const expected = { "serve.ts": 2, "dashboard.ts": 3, "daemon.ts": 2 };
|
||||
expect(SHIPPED_SKILL_NAMES).toEqual(["fusion", "computer-use"]);
|
||||
for (const [file, count] of Object.entries(expected)) {
|
||||
const source = readFileSync(join(commands, file), "utf8");
|
||||
expect([...source.matchAll(/ensureClaudeSkillsForAllProjectsOnStartup\(/g)]).toHaveLength(count);
|
||||
expect(source).toContain('from "./claude-skills-runner.js"');
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps command entry points on multi-skill delegates", () => {
|
||||
const fusionOnly = /\b(?:installFusionSkillIntoProject|ensureFusionSkillForProjects|resolveFusionSkillSource|installBundledFusionSkill)\b/;
|
||||
for (const file of ["init.ts", "project.ts", "serve.ts", "dashboard.ts", "daemon.ts", "claude-skills-runner.ts"]) {
|
||||
expect(readFileSync(join(commands, file), "utf8"), file).not.toMatch(fusionOnly);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -11,18 +12,24 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tempWorkspace } from "@fusion/test-utils";
|
||||
import {
|
||||
ensureFusionSkillForProjects,
|
||||
installFusionSkillIntoProject,
|
||||
ensureShippedSkillsForProjects,
|
||||
installShippedSkillIntoProject,
|
||||
isPiClaudeCliConfigured,
|
||||
SHIPPED_SKILL_NAMES,
|
||||
type ShippedSkillName,
|
||||
} from "../claude-skills.js";
|
||||
|
||||
function makeSourceSkill(root: string, body = "---\nname: fusion\n---\n# hi\n"): string {
|
||||
const dir = join(root, "src-skill", "fusion");
|
||||
function makeSourceSkill(root: string, skillName: ShippedSkillName): string {
|
||||
const dir = join(root, "src-skill", skillName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "SKILL.md"), body);
|
||||
writeFileSync(join(dir, "SKILL.md"), `---\nname: ${skillName}\n---\n# hi\n`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function makeSources(root: string): Record<ShippedSkillName, string> {
|
||||
return Object.fromEntries(SHIPPED_SKILL_NAMES.map((skillName) => [skillName, makeSourceSkill(root, skillName)])) as Record<ShippedSkillName, string>;
|
||||
}
|
||||
|
||||
describe("isPiClaudeCliConfigured", () => {
|
||||
it("returns false for null or empty settings", () => {
|
||||
expect(isPiClaudeCliConfigured(null)).toBe(false);
|
||||
@@ -35,12 +42,7 @@ describe("isPiClaudeCliConfigured", () => {
|
||||
});
|
||||
|
||||
it("respects explicit useClaudeCli=false even when package is present", () => {
|
||||
expect(
|
||||
isPiClaudeCliConfigured({
|
||||
useClaudeCli: false,
|
||||
packages: ["npm:pi-claude-cli"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isPiClaudeCliConfigured({ useClaudeCli: false, packages: ["npm:pi-claude-cli"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("detects pi-claude-cli in packages array", () => {
|
||||
@@ -48,142 +50,75 @@ describe("isPiClaudeCliConfigured", () => {
|
||||
expect(isPiClaudeCliConfigured({ packages: ["npm:pi-claude-cli@0.3.1"] })).toBe(true);
|
||||
expect(isPiClaudeCliConfigured({ packages: ["github:owner/pi-claude-cli"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores unrelated packages", () => {
|
||||
expect(
|
||||
isPiClaudeCliConfigured({ packages: ["npm:some-other", "npm:pi-ai"] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installFusionSkillIntoProject", () => {
|
||||
it("is a no-op when disabled", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
const source = makeSourceSkill(root);
|
||||
describe("installShippedSkillIntoProject", () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-14:30: Reconciliation outcomes must be
|
||||
* verified for every shipped skill. Adding a skill must not inherit coverage
|
||||
* only from fusion, because either target can independently be stale or foreign. */
|
||||
for (const skillName of SHIPPED_SKILL_NAMES) {
|
||||
describe(skillName, () => {
|
||||
it("skips without creating a Claude directory when disabled", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
const result = installShippedSkillIntoProject(projectPath, skillName, { source: makeSourceSkill(root, skillName), enabled: false });
|
||||
expect(result.outcome).toBe("skipped");
|
||||
expect(existsSync(join(projectPath, ".claude"))).toBe(false);
|
||||
});
|
||||
|
||||
const result = installFusionSkillIntoProject(projectPath, { source, enabled: false });
|
||||
expect(result.outcome).toBe("skipped");
|
||||
expect(existsSync(join(projectPath, ".claude"))).toBe(false);
|
||||
});
|
||||
it("installs then recognizes its current symlink", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
const source = makeSourceSkill(root, skillName);
|
||||
const first = installShippedSkillIntoProject(projectPath, skillName, { source, enabled: true });
|
||||
const second = installShippedSkillIntoProject(projectPath, skillName, { source, enabled: true });
|
||||
const target = join(projectPath, ".claude", "skills", skillName);
|
||||
expect(first.outcome).toBe("installed");
|
||||
expect(second.outcome).toBe("already-installed");
|
||||
expect(lstatSync(target).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(target)).toBe(source);
|
||||
});
|
||||
|
||||
it("creates a symlink on first install", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
const source = makeSourceSkill(root);
|
||||
it("replaces stale links and prior copies", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
const source = makeSourceSkill(root, skillName);
|
||||
const target = join(projectPath, ".claude", "skills", skillName);
|
||||
const stale = join(root, "stale", skillName);
|
||||
mkdirSync(stale, { recursive: true });
|
||||
writeFileSync(join(stale, "SKILL.md"), "# stale");
|
||||
mkdirSync(join(projectPath, ".claude", "skills"), { recursive: true });
|
||||
symlinkSync(stale, target, "dir");
|
||||
expect(installShippedSkillIntoProject(projectPath, skillName, { source, enabled: true }).outcome).toBe("replaced");
|
||||
rmSync(target);
|
||||
mkdirSync(target, { recursive: true });
|
||||
writeFileSync(join(target, "SKILL.md"), "# prior copy");
|
||||
expect(installShippedSkillIntoProject(projectPath, skillName, { source, enabled: true }).outcome).toBe("replaced");
|
||||
expect(readlinkSync(target)).toBe(source);
|
||||
});
|
||||
|
||||
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
|
||||
expect(result.outcome).toBe("installed");
|
||||
|
||||
const target = join(projectPath, ".claude", "skills", "fusion");
|
||||
expect(lstatSync(target).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(target)).toBe(source);
|
||||
expect(readFileSync(join(target, "SKILL.md"), "utf-8")).toContain("name: fusion");
|
||||
});
|
||||
|
||||
it("is idempotent when the correct symlink already exists", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
const source = makeSourceSkill(root);
|
||||
|
||||
installFusionSkillIntoProject(projectPath, { source, enabled: true });
|
||||
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
|
||||
expect(result.outcome).toBe("already-installed");
|
||||
});
|
||||
|
||||
it("replaces a stale symlink that points elsewhere", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
const source = makeSourceSkill(root);
|
||||
|
||||
// Seed a stale symlink pointing at a different dir.
|
||||
const stale = join(root, "stale");
|
||||
mkdirSync(stale, { recursive: true });
|
||||
writeFileSync(join(stale, "SKILL.md"), "# stale");
|
||||
const target = join(projectPath, ".claude", "skills", "fusion");
|
||||
mkdirSync(join(projectPath, ".claude", "skills"), { recursive: true });
|
||||
symlinkSync(stale, target, "dir");
|
||||
|
||||
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
|
||||
expect(result.outcome).toBe("replaced");
|
||||
expect(readlinkSync(target)).toBe(source);
|
||||
});
|
||||
|
||||
it("replaces a prior copy-install (plain dir with SKILL.md)", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
const source = makeSourceSkill(root);
|
||||
// Seed a prior copy — looks like a fusion skill install.
|
||||
const target = join(projectPath, ".claude", "skills", "fusion");
|
||||
mkdirSync(target, { recursive: true });
|
||||
writeFileSync(join(target, "SKILL.md"), "# old copy\n");
|
||||
|
||||
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
|
||||
expect(result.outcome).toBe("replaced");
|
||||
expect(lstatSync(target).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to clobber a foreign directory without SKILL.md", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
const source = makeSourceSkill(root);
|
||||
const target = join(projectPath, ".claude", "skills", "fusion");
|
||||
mkdirSync(target, { recursive: true });
|
||||
writeFileSync(join(target, "random.txt"), "user data");
|
||||
|
||||
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
|
||||
expect(result.outcome).toBe("failed");
|
||||
expect(readFileSync(join(target, "random.txt"), "utf-8")).toBe("user data");
|
||||
});
|
||||
|
||||
it("reports failure when source is missing", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
|
||||
const result = installFusionSkillIntoProject(projectPath, {
|
||||
source: join(root, "nonexistent"),
|
||||
enabled: true,
|
||||
it("refuses a foreign target without removing its contents", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projectPath = join(root, "project");
|
||||
const target = join(projectPath, ".claude", "skills", skillName);
|
||||
mkdirSync(target, { recursive: true });
|
||||
writeFileSync(join(target, "user-data.txt"), "keep me");
|
||||
const result = installShippedSkillIntoProject(projectPath, skillName, { source: makeSourceSkill(root, skillName), enabled: true });
|
||||
expect(result.outcome).toBe("failed");
|
||||
expect(readFileSync(join(target, "user-data.txt"), "utf8")).toBe("keep me");
|
||||
});
|
||||
});
|
||||
// Source missing -> symlink may succeed on POSIX (to a nonexistent path)
|
||||
// then later fail to resolve. The function still creates the symlink;
|
||||
// that's acceptable since fs reads will surface the broken link clearly.
|
||||
expect(["installed", "failed"]).toContain(result.outcome);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureFusionSkillForProjects", () => {
|
||||
it("skips all when disabled", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const projects = [
|
||||
{ id: "a", name: "a", path: join(root, "a") },
|
||||
{ id: "b", name: "b", path: join(root, "b") },
|
||||
];
|
||||
for (const p of projects) mkdirSync(p.path, { recursive: true });
|
||||
|
||||
const results = ensureFusionSkillForProjects(projects, { enabled: false });
|
||||
expect(results.map((r) => r.outcome)).toEqual(["skipped", "skipped"]);
|
||||
});
|
||||
|
||||
it("installs for all when enabled", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const source = makeSourceSkill(root);
|
||||
const projects = [
|
||||
{ id: "a", name: "a", path: join(root, "a") },
|
||||
{ id: "b", name: "b", path: join(root, "b") },
|
||||
];
|
||||
for (const p of projects) mkdirSync(p.path, { recursive: true });
|
||||
|
||||
const results = ensureFusionSkillForProjects(projects, { enabled: true, source });
|
||||
expect(results.map((r) => r.outcome)).toEqual(["installed", "installed"]);
|
||||
for (const p of projects) {
|
||||
expect(
|
||||
lstatSync(join(p.path, ".claude", "skills", "fusion")).isSymbolicLink(),
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
it("isolates one skill failure while reconciling the other skill", () => {
|
||||
const root = tempWorkspace("fusion-claude-skills-");
|
||||
const project = { id: "project", name: "project", path: join(root, "project") };
|
||||
mkdirSync(join(project.path, ".claude", "skills", "computer-use"), { recursive: true });
|
||||
writeFileSync(join(project.path, ".claude", "skills", "computer-use", "user-data.txt"), "foreign");
|
||||
const results = ensureShippedSkillsForProjects([project], { enabled: true, sources: makeSources(root) });
|
||||
expect(results.map((result) => result.outcome)).toEqual(["installed", "failed"]);
|
||||
expect(existsSync(join(project.path, ".claude", "skills", "fusion", "SKILL.md"))).toBe(true);
|
||||
expect(readFileSync(join(project.path, ".claude", "skills", "computer-use", "user-data.txt"), "utf8")).toBe("foreign");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COMPUTER_HANDLERS, runComputer } from "../computer.js";
|
||||
import { COMPUTER_COMMAND_SURFACE, COMPUTER_ERROR_CODES, COMPUTER_SUBCOMMANDS, type ComputerSubcommand } from "../computer/contract.js";
|
||||
|
||||
const commandsDirectory = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const FLAG_SCAN_SOURCES = [join(commandsDirectory, "computer.ts")] as const;
|
||||
const ERROR_SCAN_SOURCES = [
|
||||
join(commandsDirectory, "computer.ts"),
|
||||
join(commandsDirectory, "computer", "adapter-macos.ts"),
|
||||
join(commandsDirectory, "computer", "adapter-unsupported.ts"),
|
||||
join(commandsDirectory, "computer", "snapshot-store.ts"),
|
||||
] as const;
|
||||
const GLOBAL_FLAG_LITERALS = new Set(["--help", "--json"]);
|
||||
|
||||
function flagsFor(name: ComputerSubcommand): string[] {
|
||||
const generic = ["--app", "App"];
|
||||
switch (name) {
|
||||
case "capabilities": case "permissions": case "list-apps": return [];
|
||||
case "list-windows": return generic;
|
||||
case "get-app-state": return [...generic, "--no-screenshot"];
|
||||
case "click": return [...generic, "--element-index", "7"];
|
||||
case "set-value": return [...generic, "--element-index", "7", "--value", "value"];
|
||||
case "type-text": return [...generic, "--text", "text"];
|
||||
case "press-key": return [...generic, "--key", "enter"];
|
||||
case "hotkey": return [...generic, "--keys", "cmd+k"];
|
||||
case "scroll": return [...generic, "--direction", "down"];
|
||||
case "drag": return [...generic, "--from-x", "1", "--from-y", "2", "--to-x", "3", "--to-y", "4"];
|
||||
}
|
||||
}
|
||||
|
||||
async function envelope(args: string[]): Promise<Record<string, unknown>> {
|
||||
const output: string[] = [];
|
||||
await runComputer([...args, "--json"], {
|
||||
adapter: { listApps: async () => ({ apps: [{ bundleId: "com.example.App", name: "App", pid: 1 }] }) } as never,
|
||||
stdout: (text) => output.push(text),
|
||||
});
|
||||
return JSON.parse(output[0]!) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function removeFlag(args: string[], flag: string): string[] {
|
||||
const index = args.indexOf(flag);
|
||||
return index < 0 ? args : args.slice(0, index).concat(args[index + 1]?.startsWith("--") ? [] : [/* value is removed below */], args.slice(index + (args[index + 1]?.startsWith("--") ? 1 : 2)));
|
||||
}
|
||||
|
||||
describe("COMPUTER_COMMAND_SURFACE anchors", () => {
|
||||
it("has the same runtime subcommand set as dispatch and the contract tuple", () => {
|
||||
const descriptor = Object.keys(COMPUTER_COMMAND_SURFACE).sort();
|
||||
expect(descriptor).toEqual([...COMPUTER_SUBCOMMANDS].sort());
|
||||
expect(descriptor).toEqual(Object.keys(COMPUTER_HANDLERS).sort());
|
||||
});
|
||||
|
||||
it("keeps declared required and mutually-exclusive flags aligned with validation", async () => {
|
||||
for (const [name, entry] of Object.entries(COMPUTER_COMMAND_SURFACE) as [ComputerSubcommand, (typeof COMPUTER_COMMAND_SURFACE)[ComputerSubcommand]][]) {
|
||||
const complete = flagsFor(name);
|
||||
const valid = await envelope([name, ...complete]);
|
||||
expect((valid.error as { code?: string } | undefined)?.code, `${name} complete invocation`).not.toBe("INVALID_ARGUMENTS");
|
||||
for (const flag of entry.flags.filter((candidate) => candidate.required)) {
|
||||
const omitted = await envelope([name, ...removeFlag(complete, flag.flag)]);
|
||||
expect(omitted).toMatchObject({ ok: false, error: { code: "INVALID_ARGUMENTS", message: expect.stringContaining(flag.flag) } });
|
||||
}
|
||||
for (const flag of entry.flags.filter((candidate) => candidate.mutuallyExclusiveWith)) {
|
||||
const pair = [...complete, flag.flag, flag.valueKind === "boolean" ? "" : "value", flag.mutuallyExclusiveWith!, "value"].filter(Boolean);
|
||||
const rejected = await envelope([name, ...pair]);
|
||||
expect(rejected).toMatchObject({ ok: false, error: { code: "INVALID_ARGUMENTS" } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces the conditional invocation forms documented by the descriptor", async () => {
|
||||
await expect(envelope(["set-value", "--app", "App", "--element-index", "7"]))
|
||||
.resolves.toMatchObject({ error: { code: "INVALID_ARGUMENTS" } });
|
||||
await expect(envelope(["type-text", "--app", "App", "--text", "value", "--snapshot-id", "cs_0123456789"]))
|
||||
.resolves.toMatchObject({ error: { code: "INVALID_ARGUMENTS" } });
|
||||
await expect(envelope(["hotkey", "--app", "App", "--keys", "cmd+k", "--snapshot-id", "cs_0123456789"]))
|
||||
.resolves.toMatchObject({ error: { code: "INVALID_ARGUMENTS" } });
|
||||
await expect(envelope(["drag", "--app", "App", "--from-x", "1", "--from-y", "2"]))
|
||||
.resolves.toMatchObject({ error: { code: "INVALID_ARGUMENTS" } });
|
||||
await expect(envelope(["drag", "--app", "App", "--from-x", "1", "--from-y", "2", "--to-x", "3", "--to-y", "4", "--from-element-index", "7", "--to-element-index", "9"]))
|
||||
.resolves.toMatchObject({ error: { code: "INVALID_ARGUMENTS" } });
|
||||
});
|
||||
|
||||
it("matches parser flag literals, including optional flags", () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-07:32: validateFlags is hand-written and ignores unknown
|
||||
* flags, so reflection cannot expose optional tokens. Scan this pinned argv-reading source instead.
|
||||
* This proves only source presence, not handler use; it cannot prove unknown-flag or invalid-choice
|
||||
* rejection, which the delivered CLI intentionally does not provide. */
|
||||
const parserFlags = new Set<string>();
|
||||
for (const source of FLAG_SCAN_SOURCES) {
|
||||
for (const match of readFileSync(source, "utf8").matchAll(/["'](--[a-z][a-z0-9-]*)["']/g)) parserFlags.add(match[1]!);
|
||||
}
|
||||
for (const flag of GLOBAL_FLAG_LITERALS) parserFlags.delete(flag);
|
||||
const declared = new Set(Object.values(COMPUTER_COMMAND_SURFACE).flatMap((entry) => entry.flags.map((flag) => flag.flag)));
|
||||
expect([...parserFlags].sort()).toEqual([...declared].sort());
|
||||
});
|
||||
|
||||
it("keeps emitted envelope error literals within COMPUTER_ERROR_CODES", () => {
|
||||
const emitted = new Set<string>();
|
||||
for (const source of ERROR_SCAN_SOURCES) {
|
||||
const text = readFileSync(source, "utf8");
|
||||
for (const match of text.matchAll(/(?:new ComputerUseError\(|code:\s*)["']([A-Z][A-Z_]+)["']/g)) emitted.add(match[1]!);
|
||||
}
|
||||
expect(emitted.size).toBeGreaterThan(0);
|
||||
for (const code of emitted) expect(COMPUTER_ERROR_CODES, `${code} is not a contract code`).toContain(code as never);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COMPUTER_COMMAND_SURFACE, COMPUTER_ERROR_CODES } from "../computer/contract.js";
|
||||
import { COMPUTER_USE_GUIDE_HEADINGS, renderComputerUseGuide } from "../computer/guide.js";
|
||||
|
||||
describe("computer-use guide", () => {
|
||||
it("renders synthetic surface values without filtering", () => {
|
||||
const guide = renderComputerUseGuide({ probe: { description: "Synthetic command", flags: [{ flag: "--synthetic", valueKind: "string", required: true, description: "Synthetic flag" }] } } as never, ["SYNTHETIC_ERROR"] as never, "9.9.9");
|
||||
expect(guide).toContain("fn computer probe");
|
||||
expect(guide).toContain("--synthetic");
|
||||
expect(guide).toContain("SYNTHETIC_ERROR");
|
||||
expect(guide).toContain("v9.9.9");
|
||||
for (const heading of COMPUTER_USE_GUIDE_HEADINGS) expect(guide).toContain(`## ${heading}`);
|
||||
});
|
||||
|
||||
it("renders mutually-exclusive and conditional rules needed to invoke commands", () => {
|
||||
const guide = renderComputerUseGuide();
|
||||
expect(guide).toContain("mutually exclusive with `--value-stdin`");
|
||||
expect(guide).toContain("Supply exactly one of --value or --value-stdin.");
|
||||
expect(guide).toContain("Supply exactly one of --text or --text-stdin.");
|
||||
expect(guide).toMatch(/### fn computer press-key[\s\S]*?Rules:\n- --snapshot-id and window flags require --element-index\./);
|
||||
expect(guide).toContain("Choose exactly one form: all four coordinate flags, or both element-index flags.");
|
||||
expect(guide).toContain("Coordinate drag takes no --snapshot-id or window flags.");
|
||||
});
|
||||
|
||||
it("is the complete rendering link after descriptor anchors establish the live surface", () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-07:19: Guards 1/2b/3 anchor descriptor values to live code;
|
||||
* this guard anchors the guide to that descriptor, so together they prove completeness. Alone it
|
||||
* proves no drift claim. */
|
||||
const guide = renderComputerUseGuide();
|
||||
for (const name of Object.keys(COMPUTER_COMMAND_SURFACE)) expect(guide, `missing ${name}`).toContain(`fn computer ${name}`);
|
||||
for (const entry of Object.values(COMPUTER_COMMAND_SURFACE)) for (const flag of entry.flags) expect(guide, `missing ${flag.flag}`).toContain(flag.flag);
|
||||
for (const code of COMPUTER_ERROR_CODES) expect(guide, `missing ${code}`).toContain(code);
|
||||
for (const heading of COMPUTER_USE_GUIDE_HEADINGS) expect(guide.match(new RegExp(`## ${heading}`, "g"))?.length, heading).toBe(1);
|
||||
expect(guide).not.toMatch(/…|\.\.\.and \d+ more|\[truncated\]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import { COMPUTER_COMMAND_SURFACE } from "../computer/contract.js";
|
||||
const skill = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../../skill/computer-use/SKILL.md"), "utf8");
|
||||
describe("computer-use shipped skill", () => {
|
||||
it("has parseable frontmatter and keeps its anti-drift stub thin", () => {
|
||||
const frontmatter = skill.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
||||
expect(parse(frontmatter ?? "")).toMatchObject({ name: "computer-use" });
|
||||
expect(skill).toContain("fn skills get computer-use");
|
||||
const commands = [...skill.matchAll(/fn computer ([a-z-]+)([^\n]*)/g)];
|
||||
expect(commands.map((x) => x[1])).toEqual(["capabilities", "permissions", "list-apps"]);
|
||||
for (const [, name, tail] of commands) {
|
||||
expect(COMPUTER_COMMAND_SURFACE[name as keyof typeof COMPUTER_COMMAND_SURFACE]).toBeDefined();
|
||||
expect(tail.replace(/`$/, "")).toBe(" --json");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { installShippedSkillsIntoProject, SHIPPED_SKILL_NAMES, type ShippedSkillName } from "../claude-skills.js";
|
||||
|
||||
const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCtor, mockHybridExecutorInitialize, mockHybridExecutorShutdown } = vi.hoisted(() => ({
|
||||
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -707,6 +708,16 @@ vi.mock("../task-lifecycle.js", () => ({
|
||||
createPrReconcileGithubOps: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const { mockInstallSkillsForProject, mockEnsureSkillsOnStartup } = vi.hoisted(() => ({
|
||||
mockInstallSkillsForProject: vi.fn(() => []),
|
||||
mockEnsureSkillsOnStartup: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../claude-skills-runner.js", () => ({
|
||||
maybeInstallClaudeSkillForNewProject: mockInstallSkillsForProject,
|
||||
ensureClaudeSkillsForAllProjectsOnStartup: mockEnsureSkillsOnStartup,
|
||||
}));
|
||||
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: vi.fn().mockRejectedValue(new Error("project not initialized")),
|
||||
}));
|
||||
@@ -714,6 +725,29 @@ vi.mock("../project-context.js", () => ({
|
||||
const { runDaemon } = await import("../daemon.js");
|
||||
|
||||
describe("runDaemon", () => {
|
||||
it("C6/C7c: fn daemon drives project registration and startup multi-skill paths", async () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-07:43: This executes daemon's server options and startup
|
||||
* path; its reload startup site is separately pinned by C7d's seven-site inventory. */
|
||||
mockInstallSkillsForProject.mockClear();
|
||||
mockEnsureSkillsOnStartup.mockClear();
|
||||
const projectPath = mkdtempSync(join(tmpdir(), "fusion-daemon-skills-"));
|
||||
const sources = Object.fromEntries(SHIPPED_SKILL_NAMES.map((skillName) => {
|
||||
const source = join(projectPath, "sources", skillName);
|
||||
mkdirSync(source, { recursive: true });
|
||||
writeFileSync(join(source, "SKILL.md"), `name: ${skillName}`);
|
||||
return [skillName, source];
|
||||
})) as Record<ShippedSkillName, string>;
|
||||
mockInstallSkillsForProject.mockImplementationOnce((path: string) => installShippedSkillsIntoProject(path, { enabled: true, sources }));
|
||||
mockEnsureSkillsOnStartup.mockImplementationOnce(() => SHIPPED_SKILL_NAMES.map((skillName) => ({ outcome: "already-installed", target: skillName })));
|
||||
await runDaemon({});
|
||||
const options = mocks.createServerMock.mock.calls.at(-1)?.[1] as { onProjectRegistered?: (project: { path: string }) => void };
|
||||
options.onProjectRegistered?.({ path: projectPath });
|
||||
expect(mockInstallSkillsForProject).toHaveBeenCalledWith(projectPath);
|
||||
expect(mockInstallSkillsForProject.mock.results[0]?.value.map((result: { outcome: string }) => result.outcome)).toEqual(["installed", "installed"]);
|
||||
expect(mockEnsureSkillsOnStartup.mock.results[0]?.value.map((result: { outcome: string }) => result.outcome)).toEqual(["already-installed", "already-installed"]);
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("invokes shared startup model sync", async () => {
|
||||
const { runDaemon } = await import("../daemon.js");
|
||||
await runDaemon({});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dashboardSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "dashboard.ts"), "utf8");
|
||||
|
||||
describe("dashboard Claude skill wiring", () => {
|
||||
it("C5: both dashboard project-registration handlers use the generalized installer", () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-07:43: Dashboard boot pulls in process supervision and
|
||||
* cannot be safely started in this narrow suite; pin both callback bodies instead of a server. */
|
||||
expect([...dashboardSource.matchAll(/onProjectRegistered:\s*\(\{ path \}\)\s*=>\s*\{\s*maybeInstallClaudeSkillForNewProject\(path\);/g)]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("C7b: all dashboard startup reconciliation hooks use the generalized runner", () => {
|
||||
expect([...dashboardSource.matchAll(/ensureClaudeSkillsForAllProjectsOnStartup\(/g)]).toHaveLength(3);
|
||||
expect(dashboardSource).not.toMatch(/(?:installFusionSkillIntoProject|ensureFusionSkillForProjects)\(/);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../init.js";
|
||||
import { installShippedSkillsIntoProject, SHIPPED_SKILL_NAMES, type ShippedSkillName } from "../claude-skills.js";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { GitRepositoryInitializationError } from "@fusion/core";
|
||||
@@ -34,8 +35,14 @@ const mockGetProjectByPath = vi.fn();
|
||||
const mockRegisterProject = vi.fn();
|
||||
const mockEnsureProjectForPath = vi.fn();
|
||||
const mockUpdateProject = vi.fn().mockResolvedValue({});
|
||||
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
|
||||
const { mockIsValidSqliteDatabaseFile, mockInstallSkillsForProject } = vi.hoisted(() => ({
|
||||
mockIsValidSqliteDatabaseFile: vi.fn(),
|
||||
mockInstallSkillsForProject: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../claude-skills-runner.js", () => ({
|
||||
maybeInstallClaudeSkillForNewProject: mockInstallSkillsForProject,
|
||||
ensureClaudeSkillsForAllProjectsOnStartup: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
@@ -90,6 +97,7 @@ describe("init command", () => {
|
||||
tempHomeDir = tempDir("fn-init-home-");
|
||||
process.env.HOME = tempHomeDir;
|
||||
process.env.USERPROFILE = tempHomeDir;
|
||||
mockInstallSkillsForProject.mockClear();
|
||||
mockCentralInit.mockResolvedValue(undefined);
|
||||
mockCentralClose.mockResolvedValue(undefined);
|
||||
mockGetProjectByPath.mockResolvedValue(undefined);
|
||||
@@ -305,22 +313,40 @@ describe("init command", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("installs the bundled Fusion skill into Claude, Codex, and Gemini homes", async () => {
|
||||
it("C1: fn init reconciles every shipped skill for its new project", async () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-14:30: The init call-site contract is
|
||||
* outcomes, not just delegation: both shipped skills must reconcile. */
|
||||
const sources = Object.fromEntries(SHIPPED_SKILL_NAMES.map((skillName) => {
|
||||
const source = join(tempProjectDir, "sources", skillName);
|
||||
mkdirSync(source, { recursive: true });
|
||||
writeFileSync(join(source, "SKILL.md"), `name: ${skillName}`);
|
||||
return [skillName, source];
|
||||
})) as Record<ShippedSkillName, string>;
|
||||
mockInstallSkillsForProject.mockImplementationOnce((path: string) =>
|
||||
installShippedSkillsIntoProject(path, { enabled: true, sources }));
|
||||
|
||||
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);
|
||||
expect(mockInstallSkillsForProject).toHaveBeenCalledWith(tempProjectDir);
|
||||
expect(mockInstallSkillsForProject.mock.results[0]?.value.map((result: { outcome: string }) => result.outcome)).toEqual(["installed", "installed"]);
|
||||
for (const skillName of SHIPPED_SKILL_NAMES) {
|
||||
expect(existsSync(join(tempProjectDir, ".claude", "skills", skillName, "SKILL.md"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("installs both bundled skills into Claude, Codex, and Gemini homes", async () => {
|
||||
await runInit({ path: tempProjectDir });
|
||||
|
||||
for (const client of [".claude", ".codex", ".gemini"]) {
|
||||
for (const skillName of ["fusion", "computer-use"]) {
|
||||
expect(existsSync(join(tempHomeDir, client, "skills", skillName, "SKILL.md"))).toBe(true);
|
||||
}
|
||||
}
|
||||
const fusionTarget = join(tempHomeDir, ".claude", "skills", "fusion");
|
||||
expect(existsSync(join(fusionTarget, "references", "extension-tools.md"))).toBe(true);
|
||||
expect(existsSync(join(fusionTarget, "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 });
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
* Tests for project.ts commands
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tempWorkspace } from "@fusion/test-utils";
|
||||
import { installShippedSkillsIntoProject, SHIPPED_SKILL_NAMES, type ShippedSkillName } from "../claude-skills.js";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
@@ -44,6 +48,9 @@ const mockTaskStoreClose = vi.fn();
|
||||
const mockEnsureMemoryFileWithBackend = vi.fn();
|
||||
const mockGetTaskWorkflowSelectionAsync = vi.fn(async () => null);
|
||||
const mockGetWorkflowDefinition = vi.fn(async () => undefined);
|
||||
const { mockInstallSkillsForProject } = vi.hoisted(() => ({
|
||||
mockInstallSkillsForProject: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
/*
|
||||
FNXC:CliTests 2026-07-30-23:10 (greptile P2 — mocks bypassed the enrichment):
|
||||
@@ -106,6 +113,11 @@ vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
writeProjectIdentity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../claude-skills-runner.js", () => ({
|
||||
maybeInstallClaudeSkillForNewProject: mockInstallSkillsForProject,
|
||||
ensureClaudeSkillsForAllProjectsOnStartup: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: mockQuestion,
|
||||
@@ -140,6 +152,7 @@ describe("project commands", () => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
});
|
||||
mockGetSettings.mockResolvedValue({});
|
||||
mockInstallSkillsForProject.mockClear();
|
||||
mockFormatProjectLine.mockImplementation((project, isDefault) => `${isDefault ? "* " : " "}${project.name}`);
|
||||
mockQuestion.mockResolvedValue("y");
|
||||
mockGetProjectHealth.mockResolvedValue(undefined);
|
||||
@@ -205,6 +218,31 @@ describe("project commands", () => {
|
||||
expect(parsed[0].name).toBe("app-one");
|
||||
});
|
||||
|
||||
it("C3: runProjectAdd reconciles every shipped skill for the registered project", async () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-14:30: Project registration needs the
|
||||
* same per-skill reconciliation assertion as init, not a delegate-only spy. */
|
||||
const root = tempWorkspace("fusion-project-skills-");
|
||||
const priorCwd = process.cwd();
|
||||
const sources = Object.fromEntries(SHIPPED_SKILL_NAMES.map((skillName) => {
|
||||
const source = join(root, "sources", skillName);
|
||||
mkdirSync(source, { recursive: true });
|
||||
writeFileSync(join(source, "SKILL.md"), `name: ${skillName}`);
|
||||
return [skillName, source];
|
||||
})) as Record<ShippedSkillName, string>;
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockRegisterProject.mockResolvedValue({ id: "proj-1", name: "demo", path: root, isolationMode: "in-process" });
|
||||
mockInstallSkillsForProject.mockImplementationOnce((path: string) => installShippedSkillsIntoProject(path, { enabled: true, sources }));
|
||||
try {
|
||||
process.chdir(root);
|
||||
const { runProjectAdd } = await import("../project.js");
|
||||
await runProjectAdd("demo", ".", { force: true });
|
||||
expect(mockInstallSkillsForProject).toHaveBeenCalledWith(root);
|
||||
expect(mockInstallSkillsForProject.mock.results[0]?.value.map((result: { outcome: string }) => result.outcome)).toEqual(["installed", "installed"]);
|
||||
} finally {
|
||||
process.chdir(priorCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it("runProjectAdd registers project and prints sanitized path output", async () => {
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockRegisterProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", isolationMode: "in-process" });
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { installShippedSkillsIntoProject, SHIPPED_SKILL_NAMES, type ShippedSkillName } from "../claude-skills.js";
|
||||
|
||||
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
||||
const mock = vi.fn(function () {});
|
||||
@@ -843,6 +844,16 @@ vi.mock("../task-lifecycle.js", () => ({
|
||||
createPrReconcileGithubOps: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const { mockInstallSkillsForProject, mockEnsureSkillsOnStartup } = vi.hoisted(() => ({
|
||||
mockInstallSkillsForProject: vi.fn(() => []),
|
||||
mockEnsureSkillsOnStartup: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../claude-skills-runner.js", () => ({
|
||||
maybeInstallClaudeSkillForNewProject: mockInstallSkillsForProject,
|
||||
ensureClaudeSkillsForAllProjectsOnStartup: mockEnsureSkillsOnStartup,
|
||||
}));
|
||||
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: vi.fn().mockRejectedValue(new Error("project not initialized")),
|
||||
}));
|
||||
@@ -851,6 +862,29 @@ const { runServe } = await import("../serve.js");
|
||||
const ensureProjectRegisteredModule = await import("../ensure-project-registered.js");
|
||||
|
||||
describe("runServe", () => {
|
||||
it("C4/C7a: fn serve drives project registration and startup multi-skill paths", async () => {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-07:43: This drives serve's actual server options and
|
||||
* startup body; the second startup site remains pinned by C7d because it is a restart path. */
|
||||
mockInstallSkillsForProject.mockClear();
|
||||
mockEnsureSkillsOnStartup.mockClear();
|
||||
const projectPath = mkdtempSync(join(tmpdir(), "fusion-serve-skills-"));
|
||||
const sources = Object.fromEntries(SHIPPED_SKILL_NAMES.map((skillName) => {
|
||||
const source = join(projectPath, "sources", skillName);
|
||||
mkdirSync(source, { recursive: true });
|
||||
writeFileSync(join(source, "SKILL.md"), `name: ${skillName}`);
|
||||
return [skillName, source];
|
||||
})) as Record<ShippedSkillName, string>;
|
||||
mockInstallSkillsForProject.mockImplementationOnce((path: string) => installShippedSkillsIntoProject(path, { enabled: true, sources }));
|
||||
mockEnsureSkillsOnStartup.mockImplementationOnce(() => SHIPPED_SKILL_NAMES.map((skillName) => ({ outcome: "already-installed", target: skillName })));
|
||||
await runServe(0, {});
|
||||
const options = mocks.createServerMock.mock.calls.at(-1)?.[1] as { onProjectRegistered?: (project: { path: string }) => void };
|
||||
options.onProjectRegistered?.({ path: projectPath });
|
||||
expect(mockInstallSkillsForProject).toHaveBeenCalledWith(projectPath);
|
||||
expect(mockInstallSkillsForProject.mock.results[0]?.value.map((result: { outcome: string }) => result.outcome)).toEqual(["installed", "installed"]);
|
||||
expect(mockEnsureSkillsOnStartup.mock.results[0]?.value.map((result: { outcome: string }) => result.outcome)).toEqual(["already-installed", "already-installed"]);
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("invokes shared startup model sync", async () => {
|
||||
const { runServe } = await import("../serve.js");
|
||||
await runServe(4040, {});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSupportedSkillInstallTargets, SHIPPED_SKILL_NAMES } from "../skill-installation.js";
|
||||
|
||||
describe("bundled skill installation", () => {
|
||||
it("declares a home-directory target for every shipped skill and client", () => {
|
||||
const targets = getSupportedSkillInstallTargets("/tmp/fusion-skill-home");
|
||||
expect(SHIPPED_SKILL_NAMES).toEqual(["fusion", "computer-use"]);
|
||||
for (const skillName of SHIPPED_SKILL_NAMES) {
|
||||
expect(targets.filter((target) => target.skillName === skillName)).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
67
packages/cli/src/commands/__tests__/skills-get.test.ts
Normal file
67
packages/cli/src/commands/__tests__/skills-get.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runSkillsGet } from "../skills.js";
|
||||
import { COMPUTER_USE_GUIDE_HEADINGS } from "../computer/guide.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const cliRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const builtCli = join(cliRoot, "bin.mjs");
|
||||
|
||||
describe("fn skills get", () => {
|
||||
it("prints the in-process computer-use guide", async () => {
|
||||
let output = "";
|
||||
await expect(runSkillsGet(["computer-use"], { stdout: (x) => { output += x; } })).resolves.toBe(0);
|
||||
for (const heading of COMPUTER_USE_GUIDE_HEADINGS) expect(output).toContain(heading);
|
||||
});
|
||||
|
||||
it("rejects unknown and missing names with known skills", async () => {
|
||||
let errors = "";
|
||||
await expect(runSkillsGet(["nope"], { stderr: (x) => { errors += x; } })).resolves.toBe(1);
|
||||
expect(errors).toContain("computer-use");
|
||||
await expect(runSkillsGet([], { stderr: () => undefined })).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("renders without a guide file or PATH lookup", async () => {
|
||||
/*
|
||||
* FNXC:ComputerUseSkill 2026-08-11-07:43:
|
||||
* Rendering in this process, with no guide markdown in cwd and no PATH, makes this guide belong
|
||||
* to the exact binary that will execute computer commands instead of a stale file or other fn.
|
||||
*/
|
||||
const cwd = mkdtempSync(join(tmpdir(), "fn-skills-get-empty-"));
|
||||
const originalCwd = process.cwd();
|
||||
const originalPath = process.env.PATH;
|
||||
let output = "";
|
||||
try {
|
||||
process.chdir(cwd);
|
||||
process.env.PATH = "";
|
||||
await expect(runSkillsGet(["computer-use"], { stdout: (text) => { output += text; } })).resolves.toBe(0);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
for (const heading of COMPUTER_USE_GUIDE_HEADINGS) expect(output).toContain(heading);
|
||||
});
|
||||
|
||||
it("keeps the get branch free of markdown, network, and child-process sources", async () => {
|
||||
const source = await import("node:fs/promises").then(({ readFile }) => readFile(join(cliRoot, "src", "commands", "skills.ts"), "utf8"));
|
||||
const branch = source.slice(source.indexOf("export async function runSkillsGet"));
|
||||
expect(branch).not.toMatch(/\b(?:readFile|readFileSync|fetch|spawn|exec)\s*\(/);
|
||||
});
|
||||
|
||||
it("prints a guide and version from the same built CLI entry point", async () => {
|
||||
const guide = await execFile(process.execPath, [builtCli, "skills", "get", "computer-use"], { cwd: cliRoot });
|
||||
const version = await execFile(process.execPath, [builtCli, "--version"], { cwd: cliRoot });
|
||||
for (const heading of COMPUTER_USE_GUIDE_HEADINGS) expect(guide.stdout).toContain(heading);
|
||||
expect(guide.stdout).toContain(`# Fusion computer-use guide (v${version.stdout.trim()})`);
|
||||
|
||||
await expect(execFile(process.execPath, [builtCli, "skills", "get", "definitely-not-a-skill"], { cwd: cliRoot }))
|
||||
.rejects.toMatchObject({ code: 1, stderr: expect.stringContaining("computer-use") });
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
import { getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import {
|
||||
ensureFusionSkillForProjects,
|
||||
installFusionSkillIntoProject,
|
||||
ensureShippedSkillsForProjects,
|
||||
installShippedSkillsIntoProject,
|
||||
SHIPPED_SKILL_NAMES,
|
||||
isPiClaudeCliConfigured,
|
||||
resolveFusionSkillSource,
|
||||
resolveShippedSkillSource,
|
||||
type InstallResult,
|
||||
type ShippedSkillName,
|
||||
} from "./claude-skills.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
|
||||
@@ -33,18 +35,20 @@ export function detectPiClaudeCli(projectPath: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the fusion skill into a single newly-created project, logging the
|
||||
* Install every shipped skill into a single newly-created project, logging the
|
||||
* outcome to the console. Intended for CLI entry points (`fn init`,
|
||||
* `fn project add`) where the user is watching the output.
|
||||
*
|
||||
* No-op (and silent) when pi-claude-cli is not configured so the file layout
|
||||
* stays clean for users who only use direct Anthropic API.
|
||||
*/
|
||||
export function maybeInstallClaudeSkillForNewProject(projectPath: string): InstallResult {
|
||||
export function maybeInstallClaudeSkillForNewProject(projectPath: string): InstallResult[] {
|
||||
/* FNXC:ComputerUseSkill 2026-08-11-07:32: Every shipped skill must retain its own outcome and
|
||||
* log label so a computer-use failure cannot be misreported as a fusion-skill failure. */
|
||||
const enabled = detectPiClaudeCli(projectPath);
|
||||
const result = installFusionSkillIntoProject(projectPath, { enabled });
|
||||
logInstallResult(result, { verbose: enabled });
|
||||
return result;
|
||||
const results = installShippedSkillsIntoProject(projectPath, { enabled });
|
||||
for (const [index, result] of results.entries()) logInstallResult(result, SHIPPED_SKILL_NAMES[index]!, { verbose: enabled });
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,50 +59,51 @@ export function maybeInstallClaudeSkillForNewProject(projectPath: string): Insta
|
||||
*/
|
||||
export function ensureClaudeSkillsForAllProjectsOnStartup(
|
||||
projects: Array<{ id: string; name: string; path: string }>,
|
||||
options: {
|
||||
/** Test seam; production callers retain global pi-claude-cli detection. */
|
||||
enabled?: boolean;
|
||||
/** Test seam for isolated packaged-skill sources. */
|
||||
sources?: Partial<Record<ShippedSkillName, string | null>>;
|
||||
} = {},
|
||||
): InstallResult[] {
|
||||
if (projects.length === 0) return [];
|
||||
// Detect using the first project; all share the same user-level settings.
|
||||
const enabled = detectPiClaudeCli(projects[0]!.path);
|
||||
if (!enabled) {
|
||||
return projects.map((p) => ({
|
||||
outcome: "skipped" as const,
|
||||
target: `${p.path}/.claude/skills/fusion`,
|
||||
reason: "pi-claude-cli not configured",
|
||||
}));
|
||||
}
|
||||
const source = resolveFusionSkillSource();
|
||||
const results = ensureFusionSkillForProjects(projects, { enabled, source });
|
||||
const enabled = options.enabled ?? detectPiClaudeCli(projects[0]!.path);
|
||||
const sources = enabled
|
||||
? options.sources ?? Object.fromEntries(SHIPPED_SKILL_NAMES.map((name) => [name, resolveShippedSkillSource(name)]))
|
||||
: undefined;
|
||||
const results = ensureShippedSkillsForProjects(projects, { enabled, sources });
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i]!;
|
||||
if (result.outcome === "installed" || result.outcome === "replaced") {
|
||||
console.log(
|
||||
`[fusion] Installed Claude skill for project '${projects[i]!.name}' (${result.outcome}): ${result.target}`,
|
||||
`[fusion] Installed ${SHIPPED_SKILL_NAMES[i % SHIPPED_SKILL_NAMES.length]} Claude skill for project '${projects[Math.floor(i / SHIPPED_SKILL_NAMES.length)]!.name}' (${result.outcome}): ${result.target}`,
|
||||
);
|
||||
} else if (result.outcome === "failed") {
|
||||
console.warn(
|
||||
`[fusion] Could not install Claude skill for project '${projects[i]!.name}': ${result.reason ?? "unknown error"}`,
|
||||
`[fusion] Could not install ${SHIPPED_SKILL_NAMES[i % SHIPPED_SKILL_NAMES.length]} Claude skill for project '${projects[Math.floor(i / SHIPPED_SKILL_NAMES.length)]!.name}': ${result.reason ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function logInstallResult(result: InstallResult, options: { verbose: boolean }): void {
|
||||
function logInstallResult(result: InstallResult, skillName: string, options: { verbose: boolean }): void {
|
||||
switch (result.outcome) {
|
||||
case "installed":
|
||||
console.log(` ✓ Installed fusion skill at ${result.target}`);
|
||||
console.log(` ✓ Installed ${skillName} skill at ${result.target}`);
|
||||
break;
|
||||
case "replaced":
|
||||
console.log(` ✓ Refreshed fusion skill at ${result.target}`);
|
||||
console.log(` ✓ Refreshed ${skillName} skill at ${result.target}`);
|
||||
break;
|
||||
case "already-installed":
|
||||
if (options.verbose) {
|
||||
console.log(` ✓ Fusion skill already present at ${result.target}`);
|
||||
console.log(` ✓ ${skillName} skill already present at ${result.target}`);
|
||||
}
|
||||
break;
|
||||
case "failed":
|
||||
console.warn(
|
||||
` ⚠ Could not install fusion skill: ${result.reason ?? "unknown error"}`,
|
||||
` ⚠ Could not install ${skillName} skill: ${result.reason ?? "unknown error"}`,
|
||||
);
|
||||
break;
|
||||
case "skipped":
|
||||
|
||||
@@ -36,6 +36,8 @@ import { fileURLToPath } from "node:url";
|
||||
* `packages/cli/skill/fusion/` is symlinked in at this path.
|
||||
*/
|
||||
export const FUSION_SKILL_NAME = "fusion";
|
||||
export const SHIPPED_SKILL_NAMES = [FUSION_SKILL_NAME, "computer-use"] as const;
|
||||
export type ShippedSkillName = (typeof SHIPPED_SKILL_NAMES)[number];
|
||||
|
||||
/**
|
||||
* Result of an install attempt.
|
||||
@@ -99,11 +101,13 @@ export function isPiClaudeCliConfigured(
|
||||
*
|
||||
* Returns null if the directory is missing (e.g. broken install).
|
||||
*/
|
||||
export function resolveFusionSkillSource(): string | null {
|
||||
export function resolveShippedSkillSource(skillName: ShippedSkillName): string | null {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const candidate = resolve(dirname(here), "..", "..", "skill", FUSION_SKILL_NAME);
|
||||
const candidate = resolve(dirname(here), "..", "..", "skill", skillName);
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
/** Backward-compatible fusion delegate. */
|
||||
export function resolveFusionSkillSource(): string | null { return resolveShippedSkillSource(FUSION_SKILL_NAME); }
|
||||
|
||||
/**
|
||||
* Install the fusion skill into `<projectPath>/.claude/skills/fusion`.
|
||||
@@ -117,22 +121,22 @@ export function resolveFusionSkillSource(): string | null {
|
||||
*
|
||||
* Never throws: errors are captured and returned as {outcome: "failed", reason}.
|
||||
*/
|
||||
export function installFusionSkillIntoProject(
|
||||
projectPath: string,
|
||||
export function installShippedSkillIntoProject(
|
||||
projectPath: string, skillName: ShippedSkillName,
|
||||
options: { source?: string | null; enabled?: boolean } = {},
|
||||
): InstallResult {
|
||||
const target = join(projectPath, ".claude", "skills", FUSION_SKILL_NAME);
|
||||
const target = join(projectPath, ".claude", "skills", skillName);
|
||||
|
||||
if (options.enabled === false) {
|
||||
return { outcome: "skipped", target, reason: "pi-claude-cli not configured" };
|
||||
}
|
||||
|
||||
const source = options.source ?? resolveFusionSkillSource();
|
||||
const source = options.source ?? resolveShippedSkillSource(skillName);
|
||||
if (!source) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
target,
|
||||
reason: "fusion skill source directory not found in installed package",
|
||||
reason: `${skillName} skill source directory not found in installed package`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,21 +213,20 @@ export function installFusionSkillIntoProject(
|
||||
* Failures are collected but never thrown — startup must not be blocked by
|
||||
* filesystem quirks on a single project.
|
||||
*/
|
||||
export function ensureFusionSkillForProjects(
|
||||
projects: Array<{ id: string; name: string; path: string }>,
|
||||
options: { enabled: boolean; source?: string | null } = { enabled: false },
|
||||
): InstallResult[] {
|
||||
if (!options.enabled) {
|
||||
return projects.map((p) => ({
|
||||
outcome: "skipped" as const,
|
||||
target: join(p.path, ".claude", "skills", FUSION_SKILL_NAME),
|
||||
reason: "pi-claude-cli not configured",
|
||||
}));
|
||||
}
|
||||
const source = options.source ?? resolveFusionSkillSource();
|
||||
return projects.map((p) =>
|
||||
installFusionSkillIntoProject(p.path, { source, enabled: true }),
|
||||
);
|
||||
export function installShippedSkillsIntoProject(projectPath: string, options: { enabled?: boolean; sources?: Partial<Record<ShippedSkillName, string | null>> } = {}): InstallResult[] {
|
||||
return SHIPPED_SKILL_NAMES.map((skillName) => installShippedSkillIntoProject(projectPath, skillName, { enabled: options.enabled, source: options.sources?.[skillName] }));
|
||||
}
|
||||
|
||||
export function ensureShippedSkillsForProjects(projects: Array<{ id: string; name: string; path: string }>, options: { enabled: boolean; sources?: Partial<Record<ShippedSkillName, string | null>> }): InstallResult[] {
|
||||
return projects.flatMap((project) => installShippedSkillsIntoProject(project.path, options));
|
||||
}
|
||||
|
||||
/** Backward-compatible fusion delegates. */
|
||||
export function installFusionSkillIntoProject(projectPath: string, options: { source?: string | null; enabled?: boolean } = {}): InstallResult {
|
||||
return installShippedSkillIntoProject(projectPath, FUSION_SKILL_NAME, options);
|
||||
}
|
||||
export function ensureFusionSkillForProjects(projects: Array<{ id: string; name: string; path: string }>, options: { enabled: boolean; source?: string | null } = { enabled: false }): InstallResult[] {
|
||||
return projects.map((project) => installFusionSkillIntoProject(project.path, options));
|
||||
}
|
||||
|
||||
function isBrokenSymlink(path: string): boolean {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MacosComputerAdapter } from "./computer/adapter-macos.js";
|
||||
import { resolveComputerAdapter, type ComputerClock } from "./computer/adapter-registry.js";
|
||||
import type { ComputerAdapter, ResolvedComputerElement, ResolvedComputerWindow } from "./computer/adapter.js";
|
||||
import { createComputerSnapshotStore, type ComputerSnapshotStore } from "./computer/snapshot-store.js";
|
||||
import { COMPUTER_SUBCOMMANDS, ComputerUseError, failureEnvelope, isValidSnapshotId, parseAppTarget, successEnvelope, validateResult, type AppRef, type CommandName, type ComputerSubcommand } from "./computer/contract.js";
|
||||
import { COMPUTER_COMMAND_SURFACE, COMPUTER_SUBCOMMANDS, ComputerUseError, failureEnvelope, isValidSnapshotId, parseAppTarget, successEnvelope, validateResult, type AppRef, type CommandName, type ComputerSubcommand } from "./computer/contract.js";
|
||||
|
||||
export interface ComputerCommandOptions { platform?: string; projectRoot?: string; adapter?: ComputerAdapter; store?: ComputerSnapshotStore; clock?: ComputerClock; stdout?: (text: string) => void; stderr?: (text: string) => void; stdin?: () => Promise<string>; }
|
||||
export type ComputerHandler = (args: string[], options: ComputerCommandOptions) => Promise<unknown>;
|
||||
@@ -162,12 +162,12 @@ export async function runComputer(args: string[], options: ComputerCommandOption
|
||||
const subIndex = args.findIndex((item) => item !== "--json");
|
||||
const sub = subIndex < 0 ? undefined : args[subIndex];
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
emit("fn computer <capabilities|permissions|list-apps|list-windows|get-app-state|click|set-value|type-text|press-key|hotkey|scroll|drag>\nUse snapshot → act → snapshot; --snapshot-id fences the latest capture.", false, options);
|
||||
emit(`fn computer <${Object.keys(COMPUTER_COMMAND_SURFACE).join("|")}>\nUse snapshot → act → snapshot; --snapshot-id fences the latest capture.`, false, options);
|
||||
return 0;
|
||||
}
|
||||
if (!sub) return json
|
||||
? fail("computer", new ComputerUseError("INVALID_ARGUMENTS", "A computer subcommand is required."), true, options)
|
||||
: (emit("fn computer <capabilities|permissions|list-apps|list-windows|get-app-state|click|set-value|type-text|press-key|hotkey|scroll|drag>\nUse snapshot → act → snapshot; --snapshot-id fences the latest capture.", false, options), 0);
|
||||
: (emit(`fn computer <${Object.keys(COMPUTER_COMMAND_SURFACE).join("|")}>\nUse snapshot → act → snapshot; --snapshot-id fences the latest capture.`, false, options), 0);
|
||||
if (!(COMPUTER_SUBCOMMANDS as readonly string[]).includes(sub)) return fail("computer", new ComputerUseError("INVALID_ARGUMENTS", `Unknown computer subcommand: ${sub}.`), json, options);
|
||||
const name = sub as ComputerSubcommand;
|
||||
const handlerArgs = args.filter((_item, index) => index !== subIndex && _item !== "--json");
|
||||
|
||||
@@ -20,6 +20,58 @@ export const COMPUTER_SUBCOMMANDS = Object.freeze([
|
||||
"click", "set-value", "type-text", "press-key", "hotkey", "scroll", "drag",
|
||||
] as const);
|
||||
export type ComputerSubcommand = (typeof COMPUTER_SUBCOMMANDS)[number];
|
||||
|
||||
export type ComputerFlagValueKind = "string" | "integer" | "boolean";
|
||||
export interface ComputerCommandFlag {
|
||||
flag: `--${string}`;
|
||||
valueKind: ComputerFlagValueKind;
|
||||
required: boolean;
|
||||
mutuallyExclusiveWith?: `--${string}`;
|
||||
choices?: readonly string[];
|
||||
description: string;
|
||||
}
|
||||
export interface ComputerCommandSurfaceEntry {
|
||||
description: string;
|
||||
flags: readonly ComputerCommandFlag[];
|
||||
/** Cross-flag rules enforced by the hand-written validator. */
|
||||
requirements?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ComputerUseSkill 2026-08-11-07:19:
|
||||
* This descriptor is the single source for computer help and the in-process skill guide. Guards 1,
|
||||
* 2a, 2b, 3, and 6 independently connect it to dispatch, parser literals, emitted error codes, and
|
||||
* complete rendering. The shipped parser deliberately neither rejects undeclared flags nor invalid
|
||||
* enum choices, and source presence does not prove a flag remains honored. Error codes stay solely
|
||||
* in COMPUTER_ERROR_CODES so guide rendering never introduces a tautological duplicate.
|
||||
*/
|
||||
export const COMPUTER_COMMAND_SURFACE = Object.freeze({
|
||||
capabilities: { description: "Report platform support and available automation capabilities.", flags: [] },
|
||||
permissions: { description: "Report accessibility and screen-recording permission state.", flags: [] },
|
||||
"list-apps": { description: "List running applications available for selection.", flags: [] },
|
||||
"list-windows": { description: "List windows for an application.", flags: [{ flag: "--app", valueKind: "string", required: true, description: "Bundle id, exact app name, or pid target." }] },
|
||||
"get-app-state": { description: "Capture an app window and its accessible elements.", flags: [
|
||||
{ flag: "--app", valueKind: "string", required: true, description: "Application target." },
|
||||
{ flag: "--window-id", valueKind: "string", required: false, mutuallyExclusiveWith: "--window-index", description: "Window identifier." },
|
||||
{ flag: "--window-index", valueKind: "integer", required: false, mutuallyExclusiveWith: "--window-id", description: "Window position." },
|
||||
{ flag: "--no-screenshot", valueKind: "boolean", required: false, description: "Skip screenshot capture." },
|
||||
{ flag: "--restore-window", valueKind: "boolean", required: false, description: "Restore a minimized window." },
|
||||
] },
|
||||
click: { description: "Click a captured element.", flags: [
|
||||
{ flag: "--app", valueKind: "string", required: true, description: "Application target." }, { flag: "--element-index", valueKind: "integer", required: true, description: "Snapshot element index." },
|
||||
{ flag: "--snapshot-id", valueKind: "string", required: false, description: "Snapshot fence." }, { flag: "--window-id", valueKind: "string", required: false, mutuallyExclusiveWith: "--window-index", description: "Window identifier." }, { flag: "--window-index", valueKind: "integer", required: false, mutuallyExclusiveWith: "--window-id", description: "Window position." },
|
||||
] },
|
||||
"set-value": { description: "Set a captured editable element value.", flags: [
|
||||
{ flag: "--app", valueKind: "string", required: true, description: "Application target." }, { flag: "--element-index", valueKind: "integer", required: true, description: "Snapshot element index." },
|
||||
{ flag: "--value", valueKind: "string", required: false, mutuallyExclusiveWith: "--value-stdin", description: "Literal value." }, { flag: "--value-stdin", valueKind: "boolean", required: false, mutuallyExclusiveWith: "--value", description: "Read value from stdin." },
|
||||
{ flag: "--snapshot-id", valueKind: "string", required: false, description: "Snapshot fence." }, { flag: "--window-id", valueKind: "string", required: false, mutuallyExclusiveWith: "--window-index", description: "Window identifier." }, { flag: "--window-index", valueKind: "integer", required: false, mutuallyExclusiveWith: "--window-id", description: "Window position." },
|
||||
], requirements: ["Supply exactly one of --value or --value-stdin."] },
|
||||
"type-text": { description: "Type text into an app or captured element.", flags: [{ flag: "--app", valueKind: "string", required: true, description: "Application target." }, { flag: "--text", valueKind: "string", required: false, mutuallyExclusiveWith: "--text-stdin", description: "Literal text." }, { flag: "--text-stdin", valueKind: "boolean", required: false, mutuallyExclusiveWith: "--text", description: "Read text from stdin." }, { flag: "--element-index", valueKind: "integer", required: false, description: "Optional snapshot element index." }, { flag: "--snapshot-id", valueKind: "string", required: false, description: "Snapshot fence." }, { flag: "--window-id", valueKind: "string", required: false, mutuallyExclusiveWith: "--window-index", description: "Window identifier." }, { flag: "--window-index", valueKind: "integer", required: false, mutuallyExclusiveWith: "--window-id", description: "Window position." }], requirements: ["Supply exactly one of --text or --text-stdin.", "--snapshot-id and window flags require --element-index."] },
|
||||
"press-key": { description: "Press a key in an app or captured element.", flags: [{ flag: "--app", valueKind: "string", required: true, description: "Application target." }, { flag: "--key", valueKind: "string", required: true, description: "Key to press." }, { flag: "--element-index", valueKind: "integer", required: false, description: "Optional snapshot element index." }, { flag: "--snapshot-id", valueKind: "string", required: false, description: "Snapshot fence." }, { flag: "--window-id", valueKind: "string", required: false, mutuallyExclusiveWith: "--window-index", description: "Window identifier." }, { flag: "--window-index", valueKind: "integer", required: false, mutuallyExclusiveWith: "--window-id", description: "Window position." }], requirements: ["--snapshot-id and window flags require --element-index."] },
|
||||
hotkey: { description: "Send a key chord to an app.", flags: [{ flag: "--app", valueKind: "string", required: true, description: "Application target." }, { flag: "--keys", valueKind: "string", required: true, description: "Plus-separated key chord." }], requirements: ["This command takes no snapshot or window flags."] },
|
||||
scroll: { description: "Scroll an app or captured element.", flags: [{ flag: "--app", valueKind: "string", required: true, description: "Application target." }, { flag: "--direction", valueKind: "string", required: true, choices: ["up", "down", "left", "right"], description: "Scroll direction." }, { flag: "--amount", valueKind: "integer", required: false, description: "Scroll amount." }, { flag: "--element-index", valueKind: "integer", required: false, description: "Optional snapshot element index." }, { flag: "--snapshot-id", valueKind: "string", required: false, description: "Snapshot fence." }, { flag: "--window-id", valueKind: "string", required: false, mutuallyExclusiveWith: "--window-index", description: "Window identifier." }, { flag: "--window-index", valueKind: "integer", required: false, mutuallyExclusiveWith: "--window-id", description: "Window position." }], requirements: ["--snapshot-id and window flags require --element-index."] },
|
||||
drag: { description: "Drag between coordinates or two captured elements.", flags: [{ flag: "--app", valueKind: "string", required: true, description: "Application target." }, { flag: "--from-x", valueKind: "integer", required: false, description: "Starting x coordinate." }, { flag: "--from-y", valueKind: "integer", required: false, description: "Starting y coordinate." }, { flag: "--to-x", valueKind: "integer", required: false, description: "Ending x coordinate." }, { flag: "--to-y", valueKind: "integer", required: false, description: "Ending y coordinate." }, { flag: "--from-element-index", valueKind: "integer", required: false, description: "Starting element index." }, { flag: "--to-element-index", valueKind: "integer", required: false, description: "Ending element index." }, { flag: "--snapshot-id", valueKind: "string", required: false, description: "Snapshot fence for element drag." }, { flag: "--window-id", valueKind: "string", required: false, mutuallyExclusiveWith: "--window-index", description: "Window identifier." }, { flag: "--window-index", valueKind: "integer", required: false, mutuallyExclusiveWith: "--window-id", description: "Window position." }], requirements: ["Choose exactly one form: all four coordinate flags, or both element-index flags.", "Coordinate drag takes no --snapshot-id or window flags."] },
|
||||
} as const satisfies Record<ComputerSubcommand, ComputerCommandSurfaceEntry>);
|
||||
export type CommandName = `computer.${ComputerSubcommand}` | "computer";
|
||||
export const SNAPSHOT_STALE_REASONS = Object.freeze(["not-found", "superseded", "expired", "pid-changed", "window-mismatch", "window-gone"] as const);
|
||||
export type SnapshotStaleReason = (typeof SNAPSHOT_STALE_REASONS)[number];
|
||||
|
||||
62
packages/cli/src/commands/computer/guide.ts
Normal file
62
packages/cli/src/commands/computer/guide.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { COMPUTER_COMMAND_SURFACE, COMPUTER_ERROR_CODES, type ComputerCommandSurfaceEntry, type ComputerErrorCode, type ComputerSubcommand } from "./contract.js";
|
||||
|
||||
export const COMPUTER_USE_GUIDE_HEADINGS = ["Platform support & permissions", "The snapshot → act → snapshot loop", "App selection precedence", "Element indexes are snapshot-scoped and sparse", "Command reference", "JSON envelope & error codes", "Secrets via stdin", "Permission remediation"] as const;
|
||||
|
||||
export function resolveComputerUseGuideVersion(): string {
|
||||
try {
|
||||
const manifest = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "package.json");
|
||||
return (JSON.parse(readFileSync(manifest, "utf8")) as { version?: string }).version ?? "unknown";
|
||||
} catch { return "unknown"; }
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ComputerUseSkill 2026-08-11-07:19:
|
||||
* The binary renders this complete guide in-process from the versioned descriptor, never a committed
|
||||
* markdown guide. It emits every command, flag, and error code without caps or elision; descriptor
|
||||
* anchors establish drift while guide tests establish this completeness link and version match.
|
||||
*/
|
||||
export function renderComputerUseGuide(
|
||||
surface: Record<ComputerSubcommand, ComputerCommandSurfaceEntry> = COMPUTER_COMMAND_SURFACE,
|
||||
errorCodes: readonly ComputerErrorCode[] = COMPUTER_ERROR_CODES,
|
||||
version = resolveComputerUseGuideVersion(),
|
||||
): string {
|
||||
const commands = Object.entries(surface).map(([name, entry]) => {
|
||||
const flags = entry.flags.length
|
||||
? entry.flags.map((flag) => `- \`${flag.flag}\`${flag.required ? " (required)" : ""}${flag.choices ? `; choices: ${flag.choices.join(", ")}` : ""}${flag.mutuallyExclusiveWith ? `; mutually exclusive with \`${flag.mutuallyExclusiveWith}\`` : ""} — ${flag.description}`).join("\n")
|
||||
: "- No command-specific flags.";
|
||||
const requirements = entry.requirements?.length
|
||||
? `\nRules:\n${entry.requirements.map((rule) => `- ${rule}`).join("\n")}`
|
||||
: "";
|
||||
return `### fn computer ${name}\n${entry.description}\n${flags}${requirements}`;
|
||||
}).join("\n\n");
|
||||
return `# Fusion computer-use guide (v${version})
|
||||
|
||||
## Platform support & permissions
|
||||
macOS is first-class. Other platforms report \`supported: false\` from capabilities and actions fail with \`UNSUPPORTED_PLATFORM\`; stop rather than retrying.
|
||||
|
||||
## The snapshot → act → snapshot loop
|
||||
Capture state, act on that snapshot, then capture again after navigation, focus changes, scrolling, or rendering.
|
||||
|
||||
## App selection precedence
|
||||
Targets resolve by exact bundle id, then exact unambiguous app name, then \`pid:<n>\`. Ambiguous names return \`AMBIGUOUS_APP\`.
|
||||
|
||||
## Element indexes are snapshot-scoped and sparse
|
||||
Indexes are valid only for their capture and can be sparse. Never derive an index from an element count; \`--snapshot-id\` fences a capture and stale fences return \`SNAPSHOT_STALE\`.
|
||||
|
||||
## Command reference
|
||||
${commands}
|
||||
|
||||
## JSON envelope & error codes
|
||||
Every JSON response has \`schemaVersion: 1\`, \`ok\`, and \`command\`, followed by \`result\` or \`error\` (code, message, optional remediation/details).
|
||||
${errorCodes.map((code) => `- \`${code}\``).join("\n")}
|
||||
|
||||
## Secrets via stdin
|
||||
Use the stdin secret options for editable values and typed text; never put secrets in argv.
|
||||
|
||||
## Permission remediation
|
||||
When a permission error includes remediation, perform that remediation, re-check permissions, and re-capture state. Prefer semantic actions such as click and set-value over raw keyboard input.
|
||||
`;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
||||
import { isGitRepo } from "./git.js";
|
||||
import {
|
||||
installBundledFusionSkill,
|
||||
installBundledShippedSkills,
|
||||
type SkillInstallResult,
|
||||
} from "./skill-installation.js";
|
||||
|
||||
@@ -121,7 +121,7 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
||||
await addLocalStorageToGitignore(cwd);
|
||||
await warnIfQmdMissing();
|
||||
|
||||
const bundledSkillInstall = installBundledFusionSkill();
|
||||
const bundledSkillInstall = installBundledShippedSkills();
|
||||
logBundledSkillInstallResults(bundledSkillInstall.results);
|
||||
|
||||
// Register in central database
|
||||
|
||||
@@ -4,10 +4,12 @@ import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const FUSION_SKILL_NAME = "fusion";
|
||||
export const SHIPPED_SKILL_NAMES = [FUSION_SKILL_NAME, "computer-use"] as const;
|
||||
|
||||
export type SupportedSkillClient = "claude" | "codex" | "gemini";
|
||||
|
||||
export interface SkillInstallTarget {
|
||||
skillName: string;
|
||||
client: SupportedSkillClient;
|
||||
targetDir: string;
|
||||
}
|
||||
@@ -29,39 +31,30 @@ export interface InstallBundledFusionSkillResult {
|
||||
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) },
|
||||
];
|
||||
return SHIPPED_SKILL_NAMES.flatMap((skillName) => [
|
||||
{ skillName, client: "claude" as const, targetDir: join(homeDir, ".claude", "skills", skillName) },
|
||||
{ skillName, client: "codex" as const, targetDir: join(homeDir, ".codex", "skills", skillName) },
|
||||
{ skillName, client: "gemini" as const, targetDir: join(homeDir, ".gemini", "skills", skillName) },
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveBundledFusionSkillSource(): string | null {
|
||||
export function resolveBundledShippedSkillSource(skillName: string): string | null {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const source = resolve(dirname(here), "..", "..", "skill", FUSION_SKILL_NAME);
|
||||
const source = resolve(dirname(here), "..", "..", "skill", skillName);
|
||||
return existsSync(source) ? source : null;
|
||||
}
|
||||
export function resolveBundledFusionSkillSource(): string | null { return resolveBundledShippedSkillSource(FUSION_SKILL_NAME); }
|
||||
|
||||
export function installBundledFusionSkill(options: {
|
||||
/** Install every declared bundled skill into supported agent homes. */
|
||||
export function installBundledShippedSkills(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) => {
|
||||
const source = target.skillName === FUSION_SKILL_NAME ? sourceDir : resolveBundledShippedSkillSource(target.skillName);
|
||||
if (!source) return { client: target.client, targetDir: target.targetDir, outcome: "warning" as const, reason: `bundled ${target.skillName} skill source directory not found` };
|
||||
try {
|
||||
if (existsSync(target.targetDir)) {
|
||||
return {
|
||||
@@ -73,7 +66,7 @@ export function installBundledFusionSkill(options: {
|
||||
}
|
||||
|
||||
mkdirSync(dirname(target.targetDir), { recursive: true });
|
||||
cpSync(sourceDir, target.targetDir, { recursive: true });
|
||||
cpSync(source, target.targetDir, { recursive: true });
|
||||
|
||||
return {
|
||||
client: target.client,
|
||||
@@ -92,3 +85,11 @@ export function installBundledFusionSkill(options: {
|
||||
|
||||
return { sourceDir, results };
|
||||
}
|
||||
|
||||
/** Backward-compatible name; it now installs every declared shipped skill. */
|
||||
export function installBundledFusionSkill(options: {
|
||||
homeDir?: string;
|
||||
sourceDir?: string | null;
|
||||
} = {}): InstallBundledFusionSkillResult {
|
||||
return installBundledShippedSkills(options);
|
||||
}
|
||||
|
||||
@@ -208,3 +208,27 @@ export async function runSkillsInstall(
|
||||
`Installed skill from ${source}. Skills are discovered from .fusion/skills/, legacy .pi/skills/, and .agents/skills/.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Built-in guides are rendered by this process so they match the CLI executing commands. */
|
||||
export const BUILTIN_SKILL_GUIDES = ["computer-use"] as const;
|
||||
export type BuiltinSkillGuide = (typeof BUILTIN_SKILL_GUIDES)[number];
|
||||
|
||||
export async function runSkillsGet(
|
||||
args: string[],
|
||||
options: { stdout?: (text: string) => void; stderr?: (text: string) => void } = {},
|
||||
): Promise<number> {
|
||||
const write = options.stdout ?? ((text: string) => process.stdout.write(text));
|
||||
const error = options.stderr ?? ((text: string) => process.stderr.write(text));
|
||||
const name = args[0];
|
||||
if (!name) {
|
||||
error(`Usage: fn skills get <skill-name>\nKnown skills: ${BUILTIN_SKILL_GUIDES.join(", ")}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (name !== "computer-use") {
|
||||
error(`Unknown built-in skill: ${name}. Known skills: ${BUILTIN_SKILL_GUIDES.join(", ")}\n`);
|
||||
return 1;
|
||||
}
|
||||
const { renderComputerUseGuide } = await import("./computer/guide.js");
|
||||
write(renderComputerUseGuide());
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user