diff --git a/.changeset/fn-8961-computer-use-skill.md b/.changeset/fn-8961-computer-use-skill.md new file mode 100644 index 0000000000..c90c1e8344 --- /dev/null +++ b/.changeset/fn-8961-computer-use-skill.md @@ -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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fac0f752a8..f126878e59 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1353,9 +1353,13 @@ Browse and install agent skills from [skills.sh](https://skills.sh). ```bash fn skills search [--limit ] fn skills install [--skill ] +fn skills get ``` -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 | |---|---| diff --git a/docs/computer-use.md b/docs/computer-use.md index cc1881c472..70da8bfb3e 100644 --- a/docs/computer-use.md +++ b/docs/computer-use.md @@ -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. diff --git a/packages/cli/skill/computer-use/SKILL.md b/packages/cli/skill/computer-use/SKILL.md new file mode 100644 index 0000000000..9d09ed7d8c --- /dev/null +++ b/packages/cli/skill/computer-use/SKILL.md @@ -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. +--- + + + +## 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. diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index 05a305b757..45c94d8a4d 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index da8e498d56..0a7c0bf7ec 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -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 Install skills from a source fn skills install --skill Install a specific skill + fn skills get Print a built-in version-matched guide fn computer [--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 Install skills from a source"); console.log(" fn skills install --skill "); console.log(" Install a specific skill"); + console.log(" fn skills get 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; } diff --git a/packages/cli/src/commands/__tests__/claude-skills-callsites.test.ts b/packages/cli/src/commands/__tests__/claude-skills-callsites.test.ts new file mode 100644 index 0000000000..7ee3311c28 --- /dev/null +++ b/packages/cli/src/commands/__tests__/claude-skills-callsites.test.ts @@ -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); + } + }); +}); diff --git a/packages/cli/src/commands/__tests__/claude-skills.test.ts b/packages/cli/src/commands/__tests__/claude-skills.test.ts index df2b050704..b629a760b2 100644 --- a/packages/cli/src/commands/__tests__/claude-skills.test.ts +++ b/packages/cli/src/commands/__tests__/claude-skills.test.ts @@ -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 { + return Object.fromEntries(SHIPPED_SKILL_NAMES.map((skillName) => [skillName, makeSourceSkill(root, skillName)])) as Record; +} + 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"); }); }); diff --git a/packages/cli/src/commands/__tests__/computer-surface-descriptor.test.ts b/packages/cli/src/commands/__tests__/computer-surface-descriptor.test.ts new file mode 100644 index 0000000000..f5e1511bc8 --- /dev/null +++ b/packages/cli/src/commands/__tests__/computer-surface-descriptor.test.ts @@ -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> { + 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; +} + +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(); + 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(); + 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); + }); +}); diff --git a/packages/cli/src/commands/__tests__/computer-use-guide.test.ts b/packages/cli/src/commands/__tests__/computer-use-guide.test.ts new file mode 100644 index 0000000000..382bd8f326 --- /dev/null +++ b/packages/cli/src/commands/__tests__/computer-use-guide.test.ts @@ -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\]/); + }); +}); diff --git a/packages/cli/src/commands/__tests__/computer-use-skill.test.ts b/packages/cli/src/commands/__tests__/computer-use-skill.test.ts new file mode 100644 index 0000000000..0d47803a5e --- /dev/null +++ b/packages/cli/src/commands/__tests__/computer-use-skill.test.ts @@ -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"); + } + }); +}); diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index 63b0f957c9..b435d9b3e0 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -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; + 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({}); diff --git a/packages/cli/src/commands/__tests__/dashboard-claude-skills.test.ts b/packages/cli/src/commands/__tests__/dashboard-claude-skills.test.ts new file mode 100644 index 0000000000..4c8f94af41 --- /dev/null +++ b/packages/cli/src/commands/__tests__/dashboard-claude-skills.test.ts @@ -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)\(/); + }); +}); diff --git a/packages/cli/src/commands/__tests__/init.test.ts b/packages/cli/src/commands/__tests__/init.test.ts index be632b1361..dbda28d150 100644 --- a/packages/cli/src/commands/__tests__/init.test.ts +++ b/packages/cli/src/commands/__tests__/init.test.ts @@ -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; + 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 }); diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index 6d0f3142e3..a41756c9ad 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -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 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; + 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" }); diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index f267cc245f..f0a4c7792c 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -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 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; + 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, {}); diff --git a/packages/cli/src/commands/__tests__/skill-installation.test.ts b/packages/cli/src/commands/__tests__/skill-installation.test.ts new file mode 100644 index 0000000000..b0e27fff31 --- /dev/null +++ b/packages/cli/src/commands/__tests__/skill-installation.test.ts @@ -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); + } + }); +}); diff --git a/packages/cli/src/commands/__tests__/skills-get.test.ts b/packages/cli/src/commands/__tests__/skills-get.test.ts new file mode 100644 index 0000000000..85249c1624 --- /dev/null +++ b/packages/cli/src/commands/__tests__/skills-get.test.ts @@ -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") }); + }); +}); diff --git a/packages/cli/src/commands/claude-skills-runner.ts b/packages/cli/src/commands/claude-skills-runner.ts index 838a7d88e8..431ca47a40 100644 --- a/packages/cli/src/commands/claude-skills-runner.ts +++ b/packages/cli/src/commands/claude-skills-runner.ts @@ -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>; + } = {}, ): 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": diff --git a/packages/cli/src/commands/claude-skills.ts b/packages/cli/src/commands/claude-skills.ts index 7739d369d8..60caa78982 100644 --- a/packages/cli/src/commands/claude-skills.ts +++ b/packages/cli/src/commands/claude-skills.ts @@ -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 `/.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> } = {}): 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> }): 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 { diff --git a/packages/cli/src/commands/computer.ts b/packages/cli/src/commands/computer.ts index fa699ddd54..fc0fd7f9f7 100644 --- a/packages/cli/src/commands/computer.ts +++ b/packages/cli/src/commands/computer.ts @@ -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; } export type ComputerHandler = (args: string[], options: ComputerCommandOptions) => Promise; @@ -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 \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 \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"); diff --git a/packages/cli/src/commands/computer/contract.ts b/packages/cli/src/commands/computer/contract.ts index e0e218b742..268f71e7de 100644 --- a/packages/cli/src/commands/computer/contract.ts +++ b/packages/cli/src/commands/computer/contract.ts @@ -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); 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]; diff --git a/packages/cli/src/commands/computer/guide.ts b/packages/cli/src/commands/computer/guide.ts new file mode 100644 index 0000000000..4d9b7c8084 --- /dev/null +++ b/packages/cli/src/commands/computer/guide.ts @@ -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 = 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:\`. 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. +`; +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 773ce72706..d34caf24ba 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 { await addLocalStorageToGitignore(cwd); await warnIfQmdMissing(); - const bundledSkillInstall = installBundledFusionSkill(); + const bundledSkillInstall = installBundledShippedSkills(); logBundledSkillInstallResults(bundledSkillInstall.results); // Register in central database diff --git a/packages/cli/src/commands/skill-installation.ts b/packages/cli/src/commands/skill-installation.ts index 11f65a0583..9a04da4a42 100644 --- a/packages/cli/src/commands/skill-installation.ts +++ b/packages/cli/src/commands/skill-installation.ts @@ -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((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); +} diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index d3a3c42d77..8f360658e7 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -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 { + 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 \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; +}