diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 7e9722808..646d612e2 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,8 @@ "../src/main/agent-hooks/local-agent-cli-presence.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", + "../src/main/ipc/local-agent-install-dir-detection.ts", + "../src/main/ipc/tui-agent-detection-commands.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 1f9ccd162..cdee2a4ca 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -729,6 +729,69 @@ deliberately. The post-upgrade binary and version record are retained in artifacts and remove them according to your retention policy after the rollback is resolved. +## Installing Agent Skills Without A Desktop + +Orca's agent skills (CLI usage, orchestration, computer use, etc.) are normally +installed from Orca Settings, which pre-fills an `npx skills add ... --global` +command in a terminal for you to run. A headless host has no Settings UI, so +use `orca skills install` instead: + +```bash +orca skills install # list installable skills +orca skills install --skill orca-cli --skill orchestration # install globally (default) +orca skills install --skill orca-cli --local # install into the current project only +orca skills install --all # install every bundled skill +orca skills install --all --dry-run # print the npx command without running it +``` + +This resolves the same `npx skills add --skill ...` command +Settings would show you (adding `--global` unless `--local` is passed), then +runs it and forwards its output and exit code. It requires `node`/`npx` on the +host; it does not need a running Orca runtime. + +Unlike the command Settings shows, the spawned one adds `npx --yes` and `-y`. +Without them the `skills` CLI opens an interactive agent picker and blocks +forever on any allocated TTY — which includes a normal `ssh` session. Use +`--dry-run` to see the exact command that will run. + +Settings keeps that picker deliberately, because choosing which agents get a +skill is a real decision. A headless run cannot answer it, so instead of dropping +the choice Orca makes it explicitly: it passes an `--agent` list built from the +coding agents it detects on the host, plus the shared `.agents/skills` directory +it reads itself. Left to decide on its own with no agent detected, the `skills` +CLI installs into all ~75 agents it knows and leaves a config directory for each. +Override the targets yourself, or narrow to the shared directory alone: + +```bash +orca skills install --skill orca-cli --agent claude-code,codex +orca skills install --skill orca-cli --agent universal +``` + +If Orca detects no agent at all, `orca skills install` stops and asks for +`--agent` rather than guessing. + +To refresh already-installed skills, `orca skills update` mirrors the same +selection flags (`--skill`, `--all`, `--local`, `--dry-run`) and resolves to +`npx skills update ` with a matching scope flag — `--global`, or +`--project` when you pass `--local`: + +```bash +orca skills update --all # update every bundled skill globally +orca skills update --skill orca-cli --dry-run # print the npx command without running it +``` + +`orca skills update` only refreshes skills that are already installed — it exits +0 without doing anything for a skill that is missing, so install it first. More +generally, a 0 exit means the `skills` CLI ran without erroring, not that it +wrote anything; read its output to confirm what changed. + +`--json` covers the skill listing and `--dry-run`. A real run streams the +`skills` CLI's own non-JSON output and rejects `--json`. + +Both commands install onto the machine that runs them. In an Orca SSH workspace +or the WSL bridge the `orca` shim forwards commands to the Orca host, so they +refuse to run there and print the command to run on the machine you want. + ## Troubleshooting - `dlopen(): error loading libfuse.so.2`: install `libfuse2`. diff --git a/src/cli/args.ts b/src/cli/args.ts index 3d37517e7..a5150c980 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -30,6 +30,7 @@ export const BOOLEAN_FLAGS = new Set([ 'include-archived', 'interrupt', 'json', + 'local', 'messages', 'me', 'mobile', @@ -55,7 +56,7 @@ export const BOOLEAN_FLAGS = new Set([ ]) export const REPEATED_FLAG_SEPARATOR = '\u0000' -const REPEATABLE_STRING_FLAGS = new Set(['label']) +const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill']) function setFlagValue(flags: Map, name: string, value: string): void { const existing = flags.get(name) diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index ffe608ccd..6b2c7410c 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -218,7 +218,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ }, { name: 'skills', - keys: ['skills list', 'skills get'], + keys: ['skills list', 'skills get', 'skills install', 'skills update'], load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS } ] diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index d40496050..26e9ea5a7 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -1,5 +1,21 @@ +import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' +import { delimiter, dirname } from 'node:path' +import { getRepeatedStringFlag } from '../flags' +import { resolveCliCommand } from '../../main/codex-cli/command' +import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from '../../main/ipc/tui-agent-detection-commands' +import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils' +import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys' +import { + buildAgentFeatureSkillInstallArgs, + buildAgentFeatureSkillUpdateArgs +} from '../../shared/agent-feature-install-commands' type BundledSkillGuide = { name: string @@ -46,6 +62,259 @@ function writeStdout(value: string): void { process.stdout.write(value.endsWith('\n') ? value : `${value}\n`) } +function resolveSelectedSkillNames( + flags: Map, + guides: BundledSkillGuide[] +): string[] { + const requestedSkills = getRepeatedStringFlag(flags, 'skill') + const selectAll = flags.get('all') === true + if (flags.has('skill') && requestedSkills.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing required --skill') + } + if (selectAll && requestedSkills.length > 0) { + throw new RuntimeClientError('invalid_argument', 'Use either --all or --skill, not both.') + } + if (!selectAll && requestedSkills.length === 0) { + return [] + } + if (selectAll) { + return guides.map((guide) => guide.name) + } + const availableTopics = guides.map((guide) => guide.name).join(', ') + const guideByTopic = new Map( + guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide])) + ) + const canonicalNames = new Set() + for (const requested of requestedSkills) { + const guide = guideByTopic.get(requested) + if (!guide) { + throw new RuntimeClientError( + 'invalid_argument', + `Unknown skill "${requested}". Available skills: ${availableTopics}` + ) + } + canonicalNames.add(guide.name) + } + return [...canonicalNames].sort() +} + +/** PATH with the resolved npx's own directory first, so its `env node` shebang resolves. */ +function buildNpxPath(resolvedNpx: string): string { + // Why: resolveCliCommand falls back to the bare name when it finds nothing, and + // dirname('npx') is '.', so prepending it blindly would run ./npx out of the + // caller's checkout instead of reporting that npx is missing. + const own = dirname(resolvedNpx) + const existing = process.env.PATH ?? process.env.Path ?? '' + // Why: every version manager ships node beside npx in the same bin directory, + // so the resolved sibling is all the child needs to run npx's shebang. + return [own === '.' ? '' : own, existing].filter(Boolean).join(delimiter) +} + +function runNpxSkills(args: string[]): Promise { + return new Promise((resolve, reject) => { + // Why: a bare PATH lookup misses nvm/fnm/volta installs, and hardcoding + // `npx.cmd` both misses `npx.exe` shims and hides a missing npx behind + // cmd.exe's own exit code, so the friendly error below never fires. + const resolved = resolveCliCommand('npx') + let spawnCmd: string + let spawnArgs: string[] + try { + ;({ spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolved, args)) + } catch (error) { + // Why: the guard rejects cmd metacharacters, and only the resolved npx + // path can carry them here — a username like `A&B` puts them in it. + if (!(error instanceof UnsafeWindowsBatchArgumentsError)) { + reject(error) + return + } + reject( + new RuntimeClientError( + 'invalid_environment', + `Cannot run npx from "${resolved}": the path contains characters cmd.exe would ` + + 'reinterpret. Install Node.js somewhere without & | < > ^ " % ! in the path.' + ) + ) + return + } + // Why: npx is an `#!/usr/bin/env node` script, so resolving it off PATH is + // not enough — without node alongside it the child exits 127 with no + // 'error' event and the message below never fires. + const child = spawn(spawnCmd, spawnArgs, { + stdio: 'inherit', + env: { ...process.env, PATH: buildNpxPath(resolved) } + }) + // Why: a missing npx/Node on a headless host surfaces as a raw spawn ENOENT; + // wrap it so the CLI reports an actionable message like every other failure here. + child.once('error', (error) => { + const detail = error instanceof Error ? error.message : String(error) + reject( + new RuntimeClientError( + 'invalid_environment', + `Could not run npx: ${detail}. Install Node.js and ensure npx is on PATH.` + ) + ) + }) + child.once('exit', (code, signal) => { + resolve(typeof code === 'number' ? code : signal ? 1 : 0) + }) + }) +} + +type SkillMutationVerb = 'install' | 'update' + +/** Agents Orca can see on this host, as `skills --agent` keys. */ +function detectSkillsCliAgentKeys(): string[] { + const runtime = process.platform + const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime) + const detected = resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + detectCommandsInInstallDirs(probes), + runtime + ) + return detected.length === 0 ? [] : toSkillsCliAgentKeys(detected) +} + +function resolveInstallAgentKeys(flags: Map): string[] { + const requested = flags.get('agent') + if (flags.has('agent') && typeof requested !== 'string') { + throw new RuntimeClientError('invalid_argument', 'Missing required --agent') + } + if (typeof requested === 'string') { + // Why: one comma-separated value rather than a repeatable flag — `agent` is a + // single-value flag on other commands and the repeatable set is process-wide, + // so making it repeatable here would change how those parse a second --agent. + const keys = [ + ...new Set( + requested + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + ) + ] + // Why: a value like "," parses to nothing. Falling through to detection would + // be surprising, and emitting no --agent would restore the all-agents install. + if (keys.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing required --agent') + } + const unusable = keys.find((key) => !isSkillsCliAgentKeyShaped(key)) + if (unusable !== undefined) { + // Why: the skills CLI drops a value starting with `-`, which leaves it with + // no target and installs into every agent it knows. + throw new RuntimeClientError( + 'invalid_argument', + `Invalid --agent value "${unusable}". Pass agent names such as claude-code, ` + + 'codex, or universal.' + ) + } + return keys + } + const detected = detectSkillsCliAgentKeys() + if (detected.length > 0) { + return detected + } + // Why: without --agent, `skills add -y` falls into its own zero-detected branch + // and installs into every agent it knows (~75), creating config directories for + // agents this host does not have. Say so instead. + throw new RuntimeClientError( + 'invalid_environment', + 'No coding agent detected on this host, so there is no install target. Pass ' + + '--agent [,...] to choose targets explicitly — --agent universal ' + + 'writes only the shared .agents/skills directory that Orca reads.' + ) +} + +function buildNpxSkillsArgs( + verb: SkillMutationVerb, + skillNames: string[], + global: boolean, + agents: string[] +): string[] { + const skillArgs = + verb === 'install' + ? buildAgentFeatureSkillInstallArgs(skillNames, { global, yes: true, agents }) + : buildAgentFeatureSkillUpdateArgs(skillNames, { global, yes: true }) + // Why: a cold package cache makes bare `npx` prompt before it will fetch + // `skills`, which strands an unattended host just like the picker does. + return ['--yes', ...skillArgs] +} + +/** Render the exact argv a real run spawns, so --dry-run can never drift from it. */ +function formatNpxCommand(args: string[]): string { + return `npx ${args.join(' ')}` +} + +function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[]): string { + return [ + `Choose one or more skills to ${verb}:`, + ...skillNames.map((name) => ` ${name}`), + '', + `Usage: orca skills ${verb} --skill [--skill ...]`, + ` or: orca skills ${verb} --all` + ].join('\n') +} + +function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler { + return async ({ flags, json }) => { + // Why: keep the large generated table off the eager handler registry path. + const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js') + const guides = canonicalGuides(BUNDLED_SKILL_GUIDES) + const skillNames = resolveSelectedSkillNames(flags, guides) + + if (skillNames.length === 0) { + const names = guides.map((guide) => guide.name) + writeStdout( + json + ? JSON.stringify({ availableSkills: names }, null, 2) + : formatSkillSelectionHelp(verb, names) + ) + return + } + + // Why: this runs before target resolution because the answer belongs to the + // other machine — agents detected here would be the wrong host's, and a host + // that detects none would hide the forwarding problem behind that error. + if (process.env.ORCA_CLI_CWD) { + throw new RuntimeClientError( + 'invalid_environment', + `orca skills ${verb} writes to the machine that runs it, but this shell forwards ` + + `orca to the Orca host. Run the same orca skills ${verb} command on the machine ` + + "you want it on, where it can detect that host's agents." + ) + } + + const global = flags.get('local') !== true + // Why: install scopes its targets; update only refreshes what is already placed. + const agents = verb === 'install' ? resolveInstallAgentKeys(flags) : [] + const npxArgs = buildNpxSkillsArgs(verb, skillNames, global, agents) + const command = formatNpxCommand(npxArgs) + const dryRun = flags.get('dry-run') === true + + if (dryRun) { + writeStdout( + json + ? JSON.stringify({ command, skills: skillNames, global, executed: false }, null, 2) + : `${command}\n\nRerun without --dry-run to ${verb} now.` + ) + return + } + + if (json) { + // Why: a real run inherits npx's own stdout so progress stays visible live; + // that stream is not JSON, so --json can't be honored here. + throw new RuntimeClientError( + 'invalid_argument', + `orca skills ${verb} --json only supports --dry-run. Real ${verb}s stream ` + + "npx's own output, which isn't JSON." + ) + } + + // Why: stdio is inherited for the child below, so this status line must go to + // stderr — stdout is npx's own output, not this command's JSON channel. + process.stderr.write(`Running: ${command}\n`) + process.exitCode = await runNpxSkills(npxArgs) + } +} + export const SKILL_HANDLERS: Record = { 'skills list': async ({ json }) => { // Why: the embedded guide table is large, so unrelated CLI commands must not @@ -72,5 +341,7 @@ export const SKILL_HANDLERS: Record = { const full = flags.has('full') const markdown = full ? guide.fullMarkdown : guide.markdown writeStdout(json ? JSON.stringify({ name: guide.name, full, markdown }, null, 2) : markdown) - } + }, + 'skills install': createSkillMutationHandler('install'), + 'skills update': createSkillMutationHandler('update') } diff --git a/src/cli/help.ts b/src/cli/help.ts index 7dd39615e..c9d98603c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -21,6 +21,8 @@ Agent Discovery: Skills: skills list List version-matched skill guides bundled with this Orca CLI skills get Print a version-matched skill guide as Markdown + skills install Install bundled Orca skills globally via the community skills CLI + skills update Update already-installed Orca skills via the community skills CLI Environments: environment add Save a remote Orca runtime from a pairing code @@ -412,6 +414,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string { function formatCommandFlagHelp(flag: string, commandPath: string[]): string { const command = commandPath.join(' ') + if (command === 'skills install' && flag === 'agent') { + return '--agent Comma-separated install targets; default is detected agents' + } if (command === 'terminal close' && flag === 'tab') { return '--tab Close the whole tab and wait for durable persistence' } @@ -521,6 +526,8 @@ export function formatFlagHelp(flag: string): string { json: '--json Emit machine-readable JSON', key: '--key Key argument for this command', limit: '--limit Maximum number of rows to return', + local: '--local Target the current project instead of the global install', + skill: '--skill Bundled skill to act on; repeat for several', mode: '--mode Mode such as edit, diff, or both', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', modifiers: '--modifiers Modifier keys held only for this click', diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index d80c486f8..1cdee5aad 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,8 +1,36 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { delimiter } from 'node:path' +import type * as CodexCliCommandModule from '../main/codex-cli/command' -const { guideModuleLoadMock, runtimeClientConstructorMock } = vi.hoisted(() => ({ +const { + detectCommandsMock, + guideModuleLoadMock, + resolveCliCommandMock, + runtimeClientConstructorMock, + spawnMock +} = vi.hoisted(() => ({ + detectCommandsMock: vi.fn(() => new Set(['claude'])), guideModuleLoadMock: vi.fn(), - runtimeClientConstructorMock: vi.fn() + resolveCliCommandMock: vi.fn(() => 'npx'), + runtimeClientConstructorMock: vi.fn(), + spawnMock: vi.fn() +})) + +// Why: agent detection probes the real machine, so pin it or every install +// assertion depends on what the test runner happens to have installed. +vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({ + detectCommandsInInstallDirs: detectCommandsMock +})) + +// Why: override only the npx lookup so the real Windows .cmd rail still runs. +vi.mock('../main/codex-cli/command', async (importOriginal) => ({ + ...(await importOriginal()), + resolveCliCommand: resolveCliCommandMock +})) + +vi.mock('node:child_process', () => ({ + spawn: spawnMock })) vi.mock('./bundled-skill-guides.js', () => { @@ -22,38 +50,33 @@ vi.mock('./bundled-skill-guides.js', () => { markdown: '# Alpha\n\nShort.\n', fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n', aliases: ['legacy-alpha'] + }, + { + name: 'gamma', + description: + 'Use when gamma work spans several sentences describing exactly how a ' + + 'coding agent should decide whether gamma applies to the current task at hand.', + markdown: '# Gamma\n', + fullMarkdown: '# Gamma\n\n## References\n\nGamma reference.\n', + aliases: [] } ] } }) -vi.mock('./runtime-client', () => { +vi.mock('./runtime-client', async () => { + // Why: re-export the REAL error classes rather than redefining them. format.ts + // narrows with `instanceof` against ./runtime/types, so a look-alike class + // here would make every CLI error fall through to the generic `runtime_error` + // shape — mirroring the barrel keeps the mock faithful to production. + const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js') + class RuntimeClient { constructor() { runtimeClientConstructorMock() } } - class RuntimeClientError extends Error { - readonly code: string - readonly data?: unknown - - constructor(code: string, message: string, data?: unknown) { - super(message) - this.code = code - this.data = data - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } - return { RuntimeClient, RuntimeClientError, @@ -70,9 +93,19 @@ describe('orca skills CLI', () => { beforeEach(() => { vi.restoreAllMocks() runtimeClientConstructorMock.mockClear() + resolveCliCommandMock.mockReset() + resolveCliCommandMock.mockReturnValue('npx') + detectCommandsMock.mockReset() + detectCommandsMock.mockReturnValue(new Set(['claude'])) + spawnMock.mockReset() process.exitCode = undefined }) + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + it('keeps the bundled table off the eager command-registry path', async () => { vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -102,7 +135,10 @@ describe('orca skills CLI', () => { await main(['skills', 'list'], '/tmp/repo') expect(stdoutText(stdoutSpy)).toBe( - 'alpha: Use when alpha work is needed.\nzeta: Use when zeta work spans lines.\n' + 'alpha: Use when alpha work is needed.\n' + + 'gamma: Use when gamma work spans several sentences describing exactly how a ' + + 'coding agent should decide whether gamma applies to the current task at hand.\n' + + 'zeta: Use when zeta work spans lines.\n' ) expect(runtimeClientConstructorMock).not.toHaveBeenCalled() }) @@ -133,6 +169,12 @@ describe('orca skills CLI', () => { { topics: [ { name: 'alpha', description: 'Use when alpha work is needed.' }, + { + name: 'gamma', + description: + 'Use when gamma work spans several sentences describing exactly how a ' + + 'coding agent should decide whether gamma applies to the current task at hand.' + }, { name: 'zeta', description: 'Use when zeta work spans lines.' } ] }, @@ -176,7 +218,14 @@ describe('orca skills CLI', () => { expect(String(logSpy.mock.calls[1]?.[0])).toContain( 'get Print a version-matched skill guide' ) + expect(String(logSpy.mock.calls[1]?.[0])).toContain( + 'install Install bundled Orca skills' + ) + expect(String(logSpy.mock.calls[1]?.[0])).toContain( + 'update Update already-installed Orca skills' + ) expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills list') + expect(String(logSpy.mock.calls[2]?.[0])).toContain('skills update') expect(runtimeClientConstructorMock).not.toHaveBeenCalled() }) @@ -187,12 +236,715 @@ describe('orca skills CLI', () => { expect(process.exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith( - 'Unknown skill topic "missing". Available topics: alpha, zeta' + 'Unknown skill topic "missing". Available topics: alpha, gamma, zeta' ) expect(runtimeClientConstructorMock).not.toHaveBeenCalled() }) + + it('lists installable skills when no --skill/--all is given', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + [ + 'Choose one or more skills to install:', + ' alpha', + ' gamma', + ' zeta', + '', + 'Usage: orca skills install --skill [--skill ...]', + ' or: orca skills install --all', + '' + ].join('\n') + ) + expect(spawnMock).not.toHaveBeenCalled() + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + }) + + it('gives install --json (no selection) a stable schema', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--json'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify({ availableSkills: ['alpha', 'gamma', 'zeta'] }, null, 2)}\n` + ) + }) + + it('rejects combining --all with --skill', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--all', '--skill', 'alpha'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Use either --all or --skill, not both.') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects an unknown --skill name', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'missing'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown skill "missing". Available skills: alpha, gamma, zeta' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects --skill without a value', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Missing required --skill') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects --json for a real (non-dry-run) install', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha', '--json'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify( + { + id: 'local', + ok: false, + error: { + code: 'invalid_argument', + message: + "orca skills install --json only supports --dry-run. Real installs stream npx's " + + "own output, which isn't JSON." + }, + _meta: { runtimeId: null } + }, + null, + 2 + ) + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('prints the resolved install command without running it for --dry-run', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' + + 'Rerun without --dry-run to install now.\n' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('gives dry-run --json a stable schema', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--skill', 'legacy-alpha', '--dry-run', '--json'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify( + { + command: + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y', + skills: ['alpha'], + global: true, + executed: false + }, + null, + 2 + )}\n` + ) + }) + + it('drops --global for --local in the dry-run command and JSON', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--skill', 'alpha', '--local', '--dry-run'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y\n\n' + + 'Rerun without --dry-run to install now.\n' + ) + + stdoutSpy.mockClear() + await main( + ['skills', 'install', '--skill', 'alpha', '--local', '--dry-run', '--json'], + '/tmp/repo' + ) + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify( + { + command: + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y', + skills: ['alpha'], + global: false, + executed: false + }, + null, + 2 + )}\n` + ) + }) + + it('runs npx without --global for --local', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha', '--local'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('routes a resolved Windows .cmd shim through cmd.exe', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') + resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.cmd') + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + [ + '/d', + '/c', + 'C:\\Program Files\\nodejs\\npx.cmd', + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--global', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('spawns a resolved npx path directly when it is not a .cmd shim', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.exe') + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: an .exe shim must stay a direct spawn so a missing npx still raises + // ENOENT on the child instead of hiding inside cmd.exe's own exit code. + expect(spawnMock.mock.calls[0]?.[0]).toBe('C:\\Program Files\\nodejs\\npx.exe') + expect(spawnMock.mock.calls[0]?.[1]?.[0]).toBe('--yes') + }) + + it('resolves a legacy topic alias to the canonical skill name for install', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'legacy-alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--global', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('runs npx for --all and forwards its exit code', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--all'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 1, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--skill', + 'gamma', + '--skill', + 'zeta', + '--global', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + expect(process.exitCode).toBe(1) + }) + + it('propagates a spawn error as a nonzero exit', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('error', new Error('spawn npx ENOENT')) + await resultPromise + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Could not run npx: spawn npx ENOENT. Install Node.js and ensure npx is on PATH.' + ) + }) + + it('lists updatable skills when no --skill/--all is given', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'update'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + [ + 'Choose one or more skills to update:', + ' alpha', + ' gamma', + ' zeta', + '', + 'Usage: orca skills update --skill [--skill ...]', + ' or: orca skills update --all', + '' + ].join('\n') + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('prints the resolved update command without running it for --dry-run', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'update', '--skill', 'legacy-alpha', '--dry-run'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + 'npx --yes skills update alpha --global -y\n\nRerun without --dry-run to update now.\n' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('selects project scope for --local on update', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main( + ['skills', 'update', '--skill', 'alpha', '--local', '--dry-run', '--json'], + '/tmp/repo' + ) + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify( + { + command: 'npx --yes skills update alpha --project -y', + skills: ['alpha'], + global: false, + executed: false + }, + null, + 2 + )}\n` + ) + }) + + it('runs local updates with explicit project scope', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'update', '--skill', 'alpha', '--local'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + ['--yes', 'skills', 'update', 'alpha', '--project', '-y'], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('refuses a real run when the shell forwards orca to the Orca host', async () => { + vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + + // Why: the SSH relay and WSL bridge run argv on the Orca host, so a real + // install there would silently skip the machine the user is sitting on. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') + }) + + it('refuses --dry-run through the host-forwarding shim too', async () => { + vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') + + // Why: the targets are resolved from THIS host's agents, so a command printed + // here would name the wrong machine's agents. Point at the target instead. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') + }) + + it('puts the resolved npx directory on the child PATH', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + resolveCliCommandMock.mockReturnValue('/home/alice/.nvm/versions/node/v22/bin/npx') + vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: npx is an `env node` script, so an off-PATH npx exits 127 with no + // 'error' event unless node ships alongside it on the child's PATH. + const env = spawnMock.mock.calls[0]?.[2]?.env + // Why: the child still needs the inherited PATH and the rest of the parent + // environment; replacing it outright breaks git, node, HOME and npm config. + expect(env?.PATH).toBe( + `/home/alice/.nvm/versions/node/v22/bin${delimiter}/usr/bin${delimiter}/bin` + ) + expect(env?.HOME ?? env?.USERPROFILE).toBe(process.env.HOME ?? process.env.USERPROFILE) + }) + + it('reports a Windows npx path cmd.exe would reinterpret', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') + resolveCliCommandMock.mockReturnValue('C:\\Users\\A&B\\npx.cmd') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('cmd.exe would reinterpret') + }) + + it('never puts the current directory on the child PATH when npx is unresolvable', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + // Why: resolveCliCommand returns the bare name when it finds nothing, and + // dirname('npx') is '.', which would run ./npx out of the caller's checkout. + resolveCliCommandMock.mockReturnValue('npx') + vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: assert the constructed value, not the ambient one — a dev PATH with a + // trailing separator carries its own '' entry and would fake a failure here. + expect(spawnMock.mock.calls[0]?.[2]?.env?.PATH).toBe(`/usr/bin${delimiter}/bin`) + }) + + it('refuses to install when Orca detects no agent, instead of targeting them all', async () => { + detectCommandsMock.mockReturnValue(new Set()) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + + // Why: `skills add -y` with nothing detected installs into every agent it + // knows (~75), creating config dirs for agents the host does not have. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('No coding agent detected') + }) + + it('honours an explicit --agent list without probing the host', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + detectCommandsMock.mockReturnValue(new Set()) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main( + ['skills', 'install', '--skill', 'alpha', '--agent', 'codex, claude-code ,codex'], + '/tmp/repo' + ) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + const argv = spawnMock.mock.calls[0]?.[1] ?? [] + const agents = argv.filter((_: string, i: number) => argv[i - 1] === '--agent') + // Why: trimmed and de-duplicated, and detection is not consulted at all. + expect(agents).toEqual(['codex', 'claude-code']) + expect(detectCommandsMock).not.toHaveBeenCalled() + }) + + it('maps detected agents onto the skills CLI namespace, not Orca ids', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + detectCommandsMock.mockReturnValue(new Set(['claude', 'cursor-agent', 'rovo'])) + + await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') + + // Why: `skills add` exits 1 on an unknown --agent, and the ids differ — + // Orca's `claude` is `claude-code` and its `rovo` is `rovodev`. + expect(stdoutText(stdoutSpy)).toContain( + '--agent claude-code --agent cursor --agent rovodev --agent universal' + ) + }) + + it('never sends --agent for an update, and never refuses on a bare host', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + // Why: update refreshes what is already placed, so the no-agent refusal that + // guards install must not reach it. + detectCommandsMock.mockReturnValue(new Set()) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'update', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: update refreshes what is already placed; it chooses no new targets. + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--agent') + }) + + it.each([ + ['a bare --agent', ['skills', 'install', '--skill', 'alpha', '--agent']], + ['an empty --agent', ['skills', 'install', '--skill', 'alpha', '--agent', '']], + ['a separator-only --agent', ['skills', 'install', '--skill', 'alpha', '--agent', ' , ,']] + ])('rejects %s instead of installing to every agent', async (_label, argv) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(argv, '/tmp/repo') + + // Why: an --agent that resolves to nothing must not fall back to detection or + // emit no --agent at all — the latter restores the ~75-agent install. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Missing required --agent') + }) + + it.each([ + ['a dash-leading value', ['skills', 'install', '--skill', 'alpha', '--agent', '-y']], + ['an inline dash value', ['skills', 'install', '--skill', 'alpha', '--agent=--copy']], + ['a value with a space', ['skills', 'install', '--skill', 'alpha', '--agent', 'a b']] + ])('rejects %s the skills CLI would silently drop', async (_label, argv) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(argv, '/tmp/repo') + + // Why: the skills CLI drops such a value, leaving it with no target — the same + // all-agents install as omitting --agent entirely. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Invalid --agent value') + }) + + it('reports forwarding, not missing agents, when a forwarded host detects none', async () => { + vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt') + detectCommandsMock.mockReturnValue(new Set()) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + + // Why: resolving targets first would hide the forwarding problem behind a + // no-agent error about the wrong machine. + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') + }) + + it('documents --agent for skills install rather than the terminal-launch flag', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['skills', 'install', '--help'], '/tmp/repo') + + const help = String(logSpy.mock.calls[0]?.[0]) + expect(help).toContain('--agent ') + expect(help).not.toContain('Launch a known TUI agent') + }) + + it('accumulates a repeated --skill instead of keeping only the last one', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main( + ['skills', 'install', '--skill', 'zeta', '--skill', 'alpha'], + '/tmp/repo' + ) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: the documented primary invocation. Dropping 'skill' from the + // repeatable-flag set silently installs one skill instead of two. + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--skill', + 'zeta', + '--global', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('collapses an alias and its canonical name into one --skill', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main( + ['skills', 'install', '--skill', 'alpha', '--skill', 'legacy-alpha', '--dry-run'], + '/tmp/repo' + ) + + expect(stdoutText(stdoutSpy)).toBe( + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' + + 'Rerun without --dry-run to install now.\n' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('reports the command it is about to run on stderr', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: stdout belongs to the child, so this record has to go to stderr. + expect(stderrSpy).toHaveBeenCalledWith( + 'Running: npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n' + ) + }) + + it('runs npx skills update for --all and forwards its exit code', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'update', '--all'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 2, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + ['--yes', 'skills', 'update', 'alpha', 'gamma', 'zeta', '--global', '-y'], + expect.objectContaining({ stdio: 'inherit' }) + ) + expect(process.exitCode).toBe(2) + }) + + it('rejects --json for a real (non-dry-run) update', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['skills', 'update', '--skill', 'alpha', '--json'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify( + { + id: 'local', + ok: false, + error: { + code: 'invalid_argument', + message: + "orca skills update --json only supports --dry-run. Real updates stream npx's " + + "own output, which isn't JSON." + }, + _meta: { runtimeId: null } + }, + null, + 2 + ) + ) + expect(spawnMock).not.toHaveBeenCalled() + }) }) function stdoutText(spy: ReturnType): string { return spy.mock.calls.map((call) => String(call[0])).join('') } + +function createFakeChild(): EventEmitter { + return new EventEmitter() +} diff --git a/src/cli/specs/skills.ts b/src/cli/specs/skills.ts index 522e6efec..a78ede618 100644 --- a/src/cli/specs/skills.ts +++ b/src/cli/specs/skills.ts @@ -9,7 +9,8 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS], notes: [ 'Reads bundled guide metadata locally without contacting the Orca runtime.', - 'With --json, prints a topics array of canonical names and one-line descriptions.' + 'With --json, prints a topics array of canonical names and one-line descriptions.', + 'Use `orca skills get ` for the full guide, or `orca skills install` to install skills.' ] }, { @@ -25,5 +26,65 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [ 'Use --json for a deterministic object containing canonical topic metadata and content.' ], examples: ['orca skills get orca-cli', 'orca skills get orchestration --full'] + }, + { + path: ['skills', 'install'], + summary: 'Install bundled Orca skills via the community skills CLI', + usage: + 'orca skills install [--skill ]... [--all] [--agent [,]] ' + + '[--local] [--dry-run] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'dry-run'], + notes: [ + 'Reads the bundled skill registry locally without contacting the Orca runtime.', + 'Resolves to the same `npx skills add --skill ...` command used by ' + + 'Orca Settings, plus the non-interactive flags an unattended host needs ' + + '(`npx --yes` and `-y`), then runs it and forwards its output and exit code.', + 'Installs globally (all projects, adds --global) by default. Use --local to install ' + + 'into the current project instead.', + 'Targets the coding agents Orca detects on this host, plus the shared ' + + '.agents/skills directory. Without an explicit target the skills CLI installs ' + + 'into every agent it knows about, which litters a host with config ' + + 'directories for agents it does not have.', + 'Use --agent [,...] to choose targets yourself, or --agent universal ' + + 'for the shared directory alone. Required when Orca detects no agent.', + 'Use --dry-run to print the resolved command without running it.', + 'With --json, the skill listing and --dry-run emit JSON; a real install streams ' + + "npx's own non-JSON output live and rejects --json.", + 'Omit --skill and --all to list installable skill names.', + 'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the install command from.' + ], + examples: [ + 'orca skills install', + 'orca skills install --skill orca-cli --skill orchestration', + 'orca skills install --skill orca-cli --local', + 'orca skills install --skill orca-cli --agent claude-code,codex', + 'orca skills install --all --dry-run' + ] + }, + { + path: ['skills', 'update'], + summary: 'Update already-installed Orca skills via the community skills CLI', + usage: 'orca skills update [--skill ]... [--all] [--local] [--dry-run] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'local', 'dry-run'], + notes: [ + 'Reads the bundled skill registry locally without contacting the Orca runtime.', + 'Resolves to the same `npx skills update ` command used by Orca Settings, ' + + 'plus the non-interactive flags an unattended host needs (`npx --yes` and `-y`), ' + + 'then runs it and forwards its output and exit code.', + 'Updates the global install (all projects, adds --global) by default. Use --local to ' + + 'update the current project instead.', + 'Only refreshes skills that are already installed; use `orca skills install` first.', + 'Use --dry-run to print the resolved command without running it.', + 'With --json, the skill listing and --dry-run emit JSON; a real update streams ' + + "npx's own non-JSON output live and rejects --json.", + 'Omit --skill and --all to list updatable skill names.', + 'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the update command from.' + ], + examples: [ + 'orca skills update', + 'orca skills update --skill orca-cli --skill orchestration', + 'orca skills update --skill orca-cli --local', + 'orca skills update --all --dry-run' + ] } ] diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts index 2db80aa35..9e40f19d9 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts @@ -119,7 +119,7 @@ describe('onboarding feature setup runner', () => { expect(text).toBe(ALL_SKILL_INSTALL_COMMAND) expect(text).toBe( - 'npx skills add https://github.com/stablyai/orca --skill orca-cli computer-use orchestration orca-linear --global' + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill computer-use --skill orchestration --skill orca-linear --global' ) }) diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx index 0b4dad98b..90e9aa9f0 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx @@ -16,7 +16,7 @@ vi.mock('./AgentSkillSetupPanel', () => ({ describe('BrowserUseSkillStep', () => { it('forwards a single-skill installed command even when setup installs a bundle', () => { const bundleInstallCommand = - 'npx skills add https://github.com/stablyai/orca --skill orca-cli orchestration --global' + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global' const updateCommand = 'npx skills update orca-cli --global' renderToStaticMarkup( diff --git a/src/shared/agent-feature-install-commands.test.ts b/src/shared/agent-feature-install-commands.test.ts index e242f9f5f..338439535 100644 --- a/src/shared/agent-feature-install-commands.test.ts +++ b/src/shared/agent-feature-install-commands.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { + buildAgentFeatureSkillInstallArgs, buildAgentFeatureSkillInstallCommand, + ORCA_CLI_SKILL_INSTALL_COMMAND, + buildAgentFeatureSkillUpdateArgs, buildAgentFeatureSkillUpdateCommand, COMPUTER_USE_SKILL_UPDATE_COMMAND, EPHEMERAL_VMS_SKILL_UPDATE_COMMAND, @@ -12,6 +15,78 @@ import { } from './agent-feature-install-commands' describe('agent feature skill commands', () => { + it('builds a global install command by default', () => { + expect(buildAgentFeatureSkillInstallCommand(['orca-cli'])).toBe( + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --global' + ) + }) + + it('drops --global when installing locally', () => { + expect(buildAgentFeatureSkillInstallCommand(['orca-cli'], { global: false })).toBe( + 'npx skills add https://github.com/stablyai/orca --skill orca-cli' + ) + }) + + it('repeats --skill per name for multi-skill installs', () => { + expect(buildAgentFeatureSkillInstallCommand(['orca-cli', 'orchestration'])).toBe( + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global' + ) + expect(buildAgentFeatureSkillInstallArgs(['orca-cli', 'orchestration'])).toEqual([ + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'orca-cli', + '--skill', + 'orchestration', + '--global' + ]) + }) + + it('keeps the copyable Settings commands interactive by default', () => { + // Why: -y skips the agent picker. A human pasting from Settings should still + // get it; only an unattended spawn opts in. + expect(buildAgentFeatureSkillInstallCommand(['orca-cli'])).not.toContain('-y') + expect(buildAgentFeatureSkillUpdateCommand('orca-cli')).not.toContain('-y') + expect(ORCA_CLI_SKILL_INSTALL_COMMAND).not.toContain('-y') + expect(ORCA_CLI_SKILL_UPDATE_COMMAND).not.toContain('-y') + }) + + it('refuses to skip prompts without an install target', () => { + // Why: -y with no --agent is the one combination that makes `skills add` + // install into every agent it knows (~75). No caller may express it. + expect(() => buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true })).toThrow( + 'An install target is required when skipping prompts.' + ) + }) + + it('refuses a target the skills CLI would drop', () => { + // Why: defence in depth behind the CLI's own check — the skills CLI silently + // drops a `-`-leading --agent value, which empties its target list and + // installs into every agent it knows. + expect(() => + buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true, agents: ['-y'] }) + ).toThrow('"-y" is not a usable install target.') + expect(() => + buildAgentFeatureSkillInstallArgs(['orca-cli'], { yes: true, agents: ['universal', 'a b'] }) + ).toThrow('"a b" is not a usable install target.') + }) + + it('appends -y and the targets for an unattended run', () => { + expect( + buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true, agents: ['universal'] }) + ).toBe( + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --global --agent universal -y' + ) + expect(buildAgentFeatureSkillUpdateCommand(['orca-cli'], { global: false, yes: true })).toBe( + 'npx skills update orca-cli --project -y' + ) + expect( + buildAgentFeatureSkillInstallArgs(['orca-cli'], { yes: true, agents: ['universal'] }).at(-1) + ).toBe('-y') + expect(buildAgentFeatureSkillUpdateArgs(['orca-cli'], { yes: true }).at(-1)).toBe('-y') + }) + it('builds single-skill update commands', () => { expect(buildAgentFeatureSkillUpdateCommand('orchestration')).toBe( 'npx skills update orchestration --global' @@ -25,6 +100,22 @@ describe('agent feature skill commands', () => { expect(() => buildAgentFeatureSkillUpdateCommand(' ')).toThrow('A skill name is required.') }) + it('builds multi-skill update commands and selects project scope for --local', () => { + expect(buildAgentFeatureSkillUpdateCommand(['orca-cli', 'orchestration'])).toBe( + 'npx skills update orca-cli orchestration --global' + ) + expect(buildAgentFeatureSkillUpdateCommand(['orca-cli'], { global: false })).toBe( + 'npx skills update orca-cli --project' + ) + expect(buildAgentFeatureSkillUpdateArgs(['orca-cli'], { global: false })).toEqual([ + 'skills', + 'update', + 'orca-cli', + '--project' + ]) + expect(() => buildAgentFeatureSkillUpdateCommand([])).toThrow('A skill name is required.') + }) + it('exports single-skill update constants without changing install bundles', () => { expect(ORCA_CLI_SKILL_UPDATE_COMMAND).toBe('npx skills update orca-cli --global') expect(COMPUTER_USE_SKILL_UPDATE_COMMAND).toBe('npx skills update computer-use --global') diff --git a/src/shared/agent-feature-install-commands.ts b/src/shared/agent-feature-install-commands.ts index 60bdc6d6b..14f3e6d84 100644 --- a/src/shared/agent-feature-install-commands.ts +++ b/src/shared/agent-feature-install-commands.ts @@ -1,3 +1,5 @@ +import { isSkillsCliAgentKeyShaped } from './skills-cli-agent-keys' + export const ORCA_SKILLS_REPOSITORY_URL = 'https://github.com/stablyai/orca' export const ORCA_CLI_SKILL_NAME = 'orca-cli' @@ -8,19 +10,84 @@ export const ORCA_LINEAR_SKILL_NAME = 'orca-linear' export const LINEAR_TICKETS_SKILL_NAME = 'linear-tickets' export const LINEAR_AGENT_SKILL_NAMES = [ORCA_LINEAR_SKILL_NAME, LINEAR_TICKETS_SKILL_NAME] as const -export function buildAgentFeatureSkillInstallCommand(skillNames: readonly string[]): string { +// Why: `yes` and `agents` default off so every Settings/onboarding string a human +// pastes keeps its interactive prompts and the CLI's own agent detection. Only an +// unattended spawn, which nothing can answer, opts in. +export type AgentFeatureSkillCommandOptions = { + global?: boolean + yes?: boolean + agents?: readonly string[] +} + +export function buildAgentFeatureSkillInstallArgs( + skillNames: readonly string[], + options: AgentFeatureSkillCommandOptions = {} +): string[] { if (skillNames.length === 0) { throw new Error('At least one skill name is required.') } - return `npx skills add ${ORCA_SKILLS_REPOSITORY_URL} --skill ${skillNames.join(' ')} --global` + const global = options.global ?? true + // Why: -y with no --agent is the one combination that makes `skills add` install + // into every agent it knows. Refuse it here so no caller can express it. + const agents = options.agents ?? [] + if (options.yes && agents.length === 0) { + throw new Error('An install target is required when skipping prompts.') + } + // Why: a value the skills CLI would drop leaves it with no target at all, which + // is the same all-agents install as passing no --agent. + const unusable = agents.find((agent) => !isSkillsCliAgentKeyShaped(agent)) + if (unusable !== undefined) { + throw new Error(`"${unusable}" is not a usable install target.`) + } + // Why: one flag per name remains compatible with both single-value and variadic parsers. + const skillArgs = skillNames.flatMap((name) => ['--skill', name]) + return [ + 'skills', + 'add', + ORCA_SKILLS_REPOSITORY_URL, + ...skillArgs, + ...(global ? ['--global'] : []), + // Why: an explicit --agent stops `skills add` calling its own detection, whose + // zero-detected branch installs into all ~75 known agents and litters a bare + // host with agent config directories it has no agent for. + ...agents.flatMap((agent) => ['--agent', agent]), + // Why: without -y `skills add` opens an interactive agent picker and blocks + // forever on any TTY, which is every ssh session. + ...(options.yes ? ['-y'] : []) + ] } -export function buildAgentFeatureSkillUpdateCommand(skillName: string): string { - const trimmedSkillName = skillName.trim() - if (!trimmedSkillName) { +export function buildAgentFeatureSkillInstallCommand( + skillNames: readonly string[], + options: AgentFeatureSkillCommandOptions = {} +): string { + return `npx ${buildAgentFeatureSkillInstallArgs(skillNames, options).join(' ')}` +} + +export function buildAgentFeatureSkillUpdateArgs( + skillNames: string | readonly string[], + options: AgentFeatureSkillCommandOptions = {} +): string[] { + const rawNames = typeof skillNames === 'string' ? [skillNames] : skillNames + const names = rawNames.map((name) => name.trim()).filter((name) => name.length > 0) + if (names.length === 0) { throw new Error('A skill name is required.') } - return `npx skills update ${trimmedSkillName} --global` + const global = options.global ?? true + return [ + 'skills', + 'update', + ...names, + global ? '--global' : '--project', + ...(options.yes ? ['-y'] : []) + ] +} + +export function buildAgentFeatureSkillUpdateCommand( + skillNames: string | readonly string[], + options: AgentFeatureSkillCommandOptions = {} +): string { + return `npx ${buildAgentFeatureSkillUpdateArgs(skillNames, options).join(' ')}` } export const ORCA_CLI_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([ diff --git a/src/shared/skills-cli-agent-keys.test.ts b/src/shared/skills-cli-agent-keys.test.ts new file mode 100644 index 000000000..4359d879c --- /dev/null +++ b/src/shared/skills-cli-agent-keys.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { + SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT, + isSkillsCliAgentKeyShaped, + SKILLS_CLI_UNIVERSAL_AGENT_KEY, + toSkillsCliAgentKeys +} from './skills-cli-agent-keys' + +// Why: the community `skills` CLI validates --agent against this namespace and +// exits 1 on anything else, so a typo here breaks installs outright. Captured +// from `skills add --agent `, which prints its own valid list (v1.5.20). +const SKILLS_CLI_VALID_AGENT_KEYS = new Set([ + 'aider-desk', + 'amp', + 'antigravity', + 'antigravity-cli', + 'astrbot', + 'autohand-code', + 'augment', + 'bob', + 'claude-code', + 'openclaw', + 'cline', + 'codearts-agent', + 'codebuddy', + 'codemaker', + 'codestudio', + 'codex', + 'command-code', + 'continue', + 'cortex', + 'crush', + 'cursor', + 'deepagents', + 'devin', + 'dexto', + 'droid', + 'eve', + 'firebender', + 'forgecode', + 'gemini-cli', + 'github-copilot', + 'goose', + 'grok', + 'hermes-agent', + 'inference-sh', + 'jazz', + 'junie', + 'iflow-cli', + 'kilo', + 'kimchi', + 'kimi-code-cli', + 'kiro-cli', + 'kode', + 'lingma', + 'loaf', + 'mcpjam', + 'mistral-vibe', + 'moxby', + 'mux', + 'opencode', + 'openhands', + 'ona', + 'pi', + 'qoder', + 'qoder-cn', + 'qwen-code', + 'replit', + 'reasonix', + 'rovodev', + 'roo', + 'tabnine-cli', + 'terramind', + 'tinycloud', + 'trae', + 'trae-cn', + 'warp', + 'windsurf', + 'zed', + 'zcode', + 'zencoder', + 'zenflow', + 'neovate', + 'pochi', + 'promptscript', + 'adal', + 'universal' +]) + +describe('skills CLI agent keys', () => { + it('only maps onto keys the skills CLI accepts', () => { + for (const [agent, key] of Object.entries(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT)) { + if (key !== null) { + expect(SKILLS_CLI_VALID_AGENT_KEYS, `${agent} -> ${key}`).toContain(key) + } + } + expect(SKILLS_CLI_VALID_AGENT_KEYS).toContain(SKILLS_CLI_UNIVERSAL_AGENT_KEY) + }) + + it('covers every agent Orca can detect', () => { + // Why: a new TuiAgent must be considered here, even if the answer is null — + // otherwise it silently falls back to universal-only with no decision made. + expect(Object.keys(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT).sort()).toEqual( + Object.keys(TUI_AGENT_CONFIG).sort() + ) + }) + + it("follows Orca's own evidence for the two non-obvious mappings", () => { + // Why: src/shared/native-chat-agent-profiles.ts states OpenClaude reads + // Claude-owned roots, so it is not unmappable. + expect(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT.openclaude).toBe('claude-code') + // Why: Orca detects trae via `traecli`, which tui-agent-config calls an alias + // only TRAE CN ships, so the CN directory is the right target. + expect(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT.trae).toBe('trae-cn') + }) + + it('rejects values the skills CLI would drop, and allows the explicit wildcard', () => { + for (const bad of ['-y', '--copy', '', ' ', 'a b', 'a,b']) { + expect(isSkillsCliAgentKeyShaped(bad), bad).toBe(false) + } + for (const good of ['claude-code', 'universal', 'trae-cn', 'inference-sh', '*']) { + expect(isSkillsCliAgentKeyShaped(good), good).toBe(true) + } + }) + + it('always includes the shared directory and drops unmappable agents', () => { + expect(toSkillsCliAgentKeys(['claude', 'rovo'])).toEqual([ + 'claude-code', + 'rovodev', + 'universal' + ]) + // Why: `omp` has no skills-CLI equivalent, so it must not reach the argv. + expect(toSkillsCliAgentKeys(['omp'])).toEqual(['universal']) + expect(toSkillsCliAgentKeys([])).toEqual(['universal']) + }) +}) diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts new file mode 100644 index 000000000..ecd4a7d96 --- /dev/null +++ b/src/shared/skills-cli-agent-keys.ts @@ -0,0 +1,84 @@ +import type { TuiAgent } from './types' + +/** + * The community `skills` CLI's own `--agent` key for each agent Orca detects. + * + * Why: `skills add` validates `--agent` against its own namespace and exits 1 on + * an unknown key, so anything we are not certain of maps to null and is dropped + * rather than guessed. Orca ids and skills keys agree less often than they look + * (`claude` is `claude-code`, `rovo` is `rovodev`, `aug` is `augment`), and some + * near-matches are different products — Orca's `aider` CLI is not the CLI's + * `aider-desk`, and Orca's `openclaude` is its `openclaw` in name only, so it + * follows Orca's own rule that OpenClaude reads Claude-owned roots. + */ +export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { + claude: 'claude-code', + 'claude-agent-teams': 'claude-code', + // Why: Orca states OpenClaude reads Claude-owned roots (native-chat-agent-profiles). + openclaude: 'claude-code', + codex: 'codex', + autohand: 'autohand-code', + opencode: 'opencode', + 'mimo-code': null, + pi: 'pi', + omp: null, + gemini: 'gemini-cli', + antigravity: 'antigravity', + aider: null, + goose: 'goose', + amp: 'amp', + kilo: 'kilo', + kiro: 'kiro-cli', + crush: 'crush', + aug: 'augment', + cline: 'cline', + codebuff: null, + 'command-code': 'command-code', + continue: 'continue', + cursor: 'cursor', + droid: 'droid', + kimi: 'kimi-code-cli', + 'mistral-vibe': 'mistral-vibe', + 'qwen-code': 'qwen-code', + rovo: 'rovodev', + hermes: 'hermes-agent', + openclaw: 'openclaw', + copilot: 'github-copilot', + grok: 'grok', + devin: 'devin', + ante: null, + // Why: Orca detects trae by `traecli`, an alias only TRAE CN ships. + trae: 'trae-cn' +} satisfies Record + +/** + * The shared `.agents/skills` target every universal agent reads. Always included + * so agents Orca cannot map still receive the skill. + */ +export const SKILLS_CLI_UNIVERSAL_AGENT_KEY = 'universal' + +/** + * Whether a value is shaped like a `skills --agent` key, or its explicit all-agents + * wildcard. + * + * Why: the skills CLI silently DROPS a `--agent` value that starts with `-`, which + * empties its target list and drops it into the same all-agents branch an omitted + * --agent does. `--agent -y` is enough to trigger it, so shape is checked, not just + * emptiness. An unknown-but-plausible key is left to the CLI, which rejects it + * loudly with its own valid list before writing anything. + */ +export function isSkillsCliAgentKeyShaped(value: string): boolean { + return /^(?:\*|[a-z0-9][a-z0-9.-]*)$/i.test(value) +} + +/** Map detected Orca agents onto `skills --agent` keys, plus the universal target. */ +export function toSkillsCliAgentKeys(detectedAgents: readonly TuiAgent[]): string[] { + const keys = new Set([SKILLS_CLI_UNIVERSAL_AGENT_KEY]) + for (const agent of detectedAgents) { + const key = SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT[agent] + if (key) { + keys.add(key) + } + } + return [...keys].sort() +}