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>
15 KiB
Computer Use CLI
fn computer inspects and operates local desktop application windows. It is designed for a safe, repeatable snapshot → act → snapshot loop: capture the current accessibility state, perform one deliberate action against that capture, then capture again before relying on the UI after navigation, focus changes, scrolling, or rendering.
Platform support: macOS is supported using only operating-system-provided
osascriptandscreencapture. Linux, Windows, and other platforms are honestly unsupported:capabilitiesandpermissionssucceed withsupported: false; all other commands fail withUNSUPPORTED_PLATFORM. Fusion does not download a helper, native module, or automation dependency.
Setup and permissions
Run the following first:
fn computer capabilities --json
fn computer permissions --json
On macOS, grant the application/process running fn both permissions in System Settings:
- Privacy & Security → Accessibility: enable the terminal application, IDE, or host process that launches
fn. - Privacy & Security → Screen Recording: enable that same host when screenshots are needed.
- Quit and reopen the host application if macOS asks for it, then rerun
fn computer permissions --json.
permissions reports each check as granted, denied, or unknown, not a guessed yes/no. It uses non-mutating preflights through the JXA Objective-C bridge: AXIsProcessTrusted for Accessibility and CGPreflightScreenCaptureAccess for Screen Recording. A probe that cannot run is unknown, never a claimed grant.
Do not use a successful screencapture as proof that Screen Recording is granted. macOS can return a normal-looking, desktop-only image when permission is denied. A denied Screen Recording grant therefore does not capture; an unknown grant may capture, but marks verifiedPermission: false.
Quick workflow
Use a bundle ID when available; it is the least ambiguous target:
fn computer list-apps --json
fn computer get-app-state --app com.apple.Safari --json
# Inspect result.snapshot.elements[].index and save result.snapshot.snapshotId.
fn computer click --app com.apple.Safari --element-index 42 --snapshot-id cs_01HABCDE123 --json
fn computer get-app-state --app com.apple.Safari --json
The first command persists the snapshot before it prints its snapshotId. The later click may run in a completely separate fn process; --snapshot-id is an optimistic-concurrency fence that confirms the snapshot is still the current capture for that app.
Element indexes are snapshot-scoped and sparse. Read indexes from snapshot.elements[].index; never derive an index from elementCount, position, or bounds. Refresh state after navigation, focus change, scrolling, or re-rendering. Semantic actions (click, set-value) are preferred over raw keys because they survive focus changes better.
App resolution for a bare --app value is: exact bundle ID, then exact unambiguous name, then pid:<n>. Ambiguous names fail with AMBIGUOUS_APP.
Commands and flags
All commands support --json.
| Command | Required flags | Optional flags |
|---|---|---|
capabilities |
— | — |
permissions |
— | — |
list-apps |
— | — |
list-windows |
--app <target> |
— |
get-app-state |
--app <target> |
--window-id <id> or --window-index <n>, --no-screenshot, --restore-window |
click |
--app, --element-index <n> |
--snapshot-id <id>, --window-id <id> or --window-index <n> |
set-value |
--app, --element-index <n>, one of --value <text> or --value-stdin |
snapshot/window flags |
type-text |
--app, one of --text <text> or --text-stdin |
--element-index, snapshot/window flags |
press-key |
--app, --key <name> |
--element-index, snapshot/window flags |
hotkey |
--app, --keys <combo> (repeatable or +-joined) |
— |
scroll |
--app, --direction up|down|left|right |
--amount <n> (default 3), --element-index, snapshot/window flags |
drag |
--app, either all --from-x --from-y --to-x --to-y or both --from-element-index --to-element-index |
snapshot/window flags for element form only |
--window-id and --window-index are mutually exclusive. The value of --snapshot-id must match ^cs_[A-Za-z0-9]{10,40}$; it is validated before filesystem access. Numeric indexes, coordinates, and amounts must be non-negative. Do not mix or partially provide drag forms. hotkey and coordinate-only drag take neither snapshot nor window flags.
Use --value-stdin and --text-stdin for secrets. Fusion does not put stdin values in output, process arguments, snapshot records, logs, audit metadata, or error messages.
Targetless type-text, press-key, and scroll act on the target app's currently focused UI element and never activate or raise the app implicitly. If nothing is focused, they fail with ACTION_FAILED; focus the app yourself or pass --element-index.
JSON contract (schema version 1)
Every --json invocation prints exactly one object to stdout and nothing else. Success has result only; failure has error only:
{"schemaVersion":1,"ok":true,"command":"computer.get-app-state","result":{}}
{"schemaVersion":1,"ok":false,"command":"computer.click","error":{"code":"SNAPSHOT_STALE","message":"Snapshot expired.","remediation":"Re-run `fn computer get-app-state --app <app>`.","details":{"reason":"expired"}}}
schemaVersion is literal 1 and changes only for breaking reshapes. command is computer.<subcommand> using a runtime-inventory token. Group-routing failures (unknown or missing subcommand in JSON mode) uniquely use command: "computer". error.details, when present, is a flat map of string, number, or boolean values and never contains a secret. Diagnostics go to stderr; in human mode successful summaries go to stdout and failures are error: <code>: <message> on stderr.
The runtime exports frozen COMPUTER_SUBCOMMANDS, COMPUTER_ACTIONS, and COMPUTER_ERROR_CODES tuples; their TypeScript unions and dispatcher derive from them. Adding a command requires updating this runtime inventory. Exit code is 0 for success and 1 for every failure. Error codes are append-only:
| Code | Meaning |
|---|---|
UNSUPPORTED_PLATFORM |
No supported adapter (except capability/permission reporting) |
PERMISSION_DENIED |
Definitively denied required permission or missing required built-in |
PERMISSION_UNVERIFIED |
Unknown probe followed by a permission-shaped OS failure |
INVALID_ARGUMENTS |
Invalid command, flags, number, enum, or snapshot ID |
APP_NOT_FOUND / AMBIGUOUS_APP |
No app match / more than one name match |
WINDOW_NOT_FOUND |
Supplied live window selector did not match |
SNAPSHOT_REQUIRED |
An element action has no snapshot for the app |
SNAPSHOT_STALE |
Snapshot is no longer usable; see below |
ELEMENT_INDEX_NOT_FOUND |
Sparse index is absent from the snapshot |
ELEMENT_UNRESOLVABLE |
Recorded locator no longer resolves or identity differs |
ACTION_UNSUPPORTED / ACTION_FAILED |
Adapter does not support action / OS action failed |
SCREENSHOT_FAILED |
Screenshot-only failure, reported in screenshotError rather than top-level error |
TIMEOUT / INTERNAL |
Timed-out seam / unexpected, redacted failure |
Remediation is required for unsupported platform, permission denied/unverified, snapshot required/stale, missing/unresolvable element, ambiguous app, and unsupported action.
Result values
capabilities:{ platform, adapterId, supported, actions, unsupportedActions, features: { screenshot, restoreWindow, stdinSecrets, crossInvocationSnapshots } }. Every action is in exactly one action list.permissions:{ platform, adapterId, supported, allGranted, checks }, where each check is{ id: "accessibility"|"screen-recording", status: "granted"|"denied"|"unknown", granted, probed, probe, detail, remediation }.grantedis exactlystatus === "granted";allGrantedis the AND of checks.list-apps:{ apps }, sorted by name. An app is{ bundleId, name, pid }.list-windows:{ app, windows }. A window is{ windowId, windowIndex, title, bounds, minimized }.get-app-state:{ app, window, snapshot, screenshot, screenshotError? }.snapshothas{ snapshotId, targetKey, windowKey, capturedAt, expiresAt, treeText, elementCount, truncated, elements }. An element is{ index, role, title, value, label, enabled, focused, bounds, actions, locator }; its locator is{ kind: "ax-path", path, role, subrole, identifier, title }.- Actions return
{ action, app, snapshotId, elementIndex, fromElementIndex, toElementIndex, performed: true }only after the OS action succeeds. Single-endpoint actions useelementIndex; element-form drag usesfromElementIndexandtoElementIndexwithelementIndex: null; hotkey reports all index fields andsnapshotIdasnull.
Screenshots are always paths, never base64, data: URLs, or byte arrays. --no-screenshot produces screenshot: null with no screenshotError; successful capture has screenshot and no error; failed/not-captured has screenshot: null and screenshotError. Screenshot verifiedPermission is true only for a preflight-confirmed Screen Recording grant.
Permissions outcome matrix
| Check/status | Command behavior |
|---|---|
| Accessibility granted | Proceed. |
| Accessibility denied | Before automation, fail PERMISSION_DENIED with details.permission: "accessibility". |
| Accessibility unknown | Attempt the call. A permission-shaped failure becomes PERMISSION_UNVERIFIED; other failures are ACTION_FAILED or TIMEOUT. This applies to window/locator replay too. |
capabilities or permissions |
Never fail because of permission status; return status data. |
| Screen Recording granted | Capture; successful image has verifiedPermission: true; a failed capture is SCREENSHOT_FAILED or TIMEOUT. |
| Screen Recording denied | Do not capture; return screenshot null and screenshotError.code: "PERMISSION_DENIED". |
| Screen Recording unknown | Attempt capture; success has verifiedPermission: false; failure is PERMISSION_UNVERIFIED or TIMEOUT. |
Any Screen Recording status with --no-screenshot |
Do not probe or capture; screenshot is null without an error. |
Missing osascript |
Checks are unknown; non-reporting commands fail PERMISSION_DENIED with details.missingBinary: "osascript". |
Missing screencapture |
Only screenshot degrades with SCREENSHOT_FAILED. |
Durable snapshots and safe element replay
Snapshots are stored per project at .fusion/computer-use/snapshots/<snapshotId>.json; the app's latest pointer is .fusion/computer-use/latest/<targetKeySlug>.json. They are never shared across project roots. Capture atomically persists the record and pointer before returning snapshotId.
A resolved app has app-scoped targetKey (bundle:<bundleId>, or pid:<pid>) and window-scoped windowKey (<targetKey>#<windowId>). There is one latest pointer per app, not per window. An action with no --snapshot-id uses that latest snapshot. Action window flags are optional assertions: a supplied selector that differs from the recorded window produces SNAPSHOT_STALE / window-mismatch.
Snapshots expire after five minutes by default; expiresAt publishes the exact deadline. An explicit snapshot ID is a concurrency fence, not a way to revive old UI: a superseded ID fails. Before acting, Fusion re-resolves the recorded window and then the locator rooted in it, verifying role, subrole, and recorded identifier. It never acts on a new occupant of the old path and never falls back to saved bounds/coordinates.
| Failure | When | Recovery |
|---|---|---|
SNAPSHOT_REQUIRED |
No latest snapshot exists for the target app | Run get-app-state. |
SNAPSHOT_STALE |
not-found, superseded, expired, pid-changed, window-mismatch, or window-gone |
Run get-app-state; use the current window and snapshot. |
ELEMENT_INDEX_NOT_FOUND |
Index is absent from sparse map | Read the current elements[].index after re-snapshotting. |
ELEMENT_UNRESOLVABLE |
Locator path fails or identity no longer matches | Re-snapshot; do not retry with coordinates. |
Deterministic failure order and timeouts
Exactly one error is emitted: the first failing stage wins. The order is group routing; flag parsing; platform resolution; action support; built-in and permission gate; stdin read; app resolution; supplied-window resolution; snapshot resolution; sparse element lookup; window/locator replay; OS call. Thus malformed flags beat unsupported platform, and a permission-shaped replay error is PERMISSION_UNVERIFIED, not an element error. Unexpected failures become a redacted INTERNAL envelope, never a stack trace on stdout.
No user timeout flag is provided. Default seam timeouts are: permission probe 5 seconds; app/window listing 10 seconds; state capture 20 seconds; screenshot 15 seconds; locator replay 10 seconds; action 10 seconds.
Orca prior art and Fusion differences
This surface was informed by Orca and its computer-use CLI documentation. 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.