feat(cli): add orca skills install and orca skills update for headless skill setup (#9201)

Adds `orca skills install` and `orca skills update` so skills can be set up without the GUI — SSH hosts, containers, CI. Previously `orca skills` had only `list` and `get`, so there was no headless path.

**Agent targeting is scoped explicitly rather than delegated to detection.** The `skills` CLI decides which agents to install into, and with `-y` and zero detected agents it takes `targetAgents = validAgents` — all ~75. That is not a corner case for a headless CLI: a fresh SSH box or container with no agent installed is the normal starting state. Measured on a bare host, the unscoped command created **52 top-level agent directories and 54 junctions** (one real payload in `~/.agents/skills`, the rest links) on Windows, and 52/53 on macOS.

The CLI now passes `--agent` derived from Orca's own detection, mapped to the `skills` key namespace, plus `universal`. Supplying `--agent` makes `runAdd` use it directly and never call `detectInstalledAgents()`, so the fan-out branch is unreachable. On a bare host it now refuses with `No coding agent detected on this host` and exit 1, creating nothing. Same command with scoping: **1 directory, 0 junctions.**

`universal` alone would under-install — Claude Code is not in that set, and 19 of 28 mapped keys write agent-private homes `universal` never touches. `--agent '*'` is the bug itself. The mapping is hedged three ways: `null` for any agent whose key could not be confirmed, `satisfies Record<TuiAgent, …>` so a new Orca agent is a compile error, and a test pinning every mapped key against the CLI's own valid list.

Fixed during review — two holes that each restored the full fan-out through a different door:
- `--agent ','` trimmed to nothing, which skipped the refusal *and* emitted no `--agent`.
- `--agent -y` passed an emptiness check, and the vendor CLI silently drops `-`-leading values, re-emptying its list.

The real invariant is argument *shape*, not emptiness, and it is now enforced at the choke point in `buildAgentFeatureSkillInstallArgs`, so no caller can emit `-y` without a usable target. `*` remains allowed — asking for every agent explicitly is a choice, not an accident. Verified with 51 hostile inputs through the built binary, each recorded argv replayed through the vendor's own parser.

Also fixed: the `ORCA_CLI_CWD` refusal now runs before target resolution (it was quoting the wrong host's agent list), and `--dry-run` is refused in a forwarded shell rather than printing a command naming the wrong machine.

Validated on a real Windows host across PowerShell 7, PowerShell 5.1, cmd.exe and Git Bash: `.cmd` shims route through `cmd.exe` and `.exe` shims spawn directly (proved with instrumented shims, not inferred), the ENOENT path produces an actionable error rather than a silent failure, and `skills update` genuinely restores a corrupted skill byte-for-byte.

Known, not addressed here — both upstream behaviours this only forwards: a partial install failure exits 0, and "no installed skills found" exits 0. Both are invisible to the headless callers this feature exists for.

Co-authored-by: scastanoh21 <scastanoh21@gmail.com>
This commit is contained in:
Sebastián Castaño 2026-07-30 13:20:29 -05:00 committed by GitHub
parent e20554bfd7
commit 676ef7fab8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1574 additions and 38 deletions

View File

@ -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",

View File

@ -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 <repo> --skill <name> ...` 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 <names...>` 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`.

View File

@ -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<string, string | boolean>, name: string, value: string): void {
const existing = flags.get(name)

View File

@ -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
}
]

View File

@ -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<string, string | boolean>,
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<string, BundledSkillGuide>(
guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide]))
)
const canonicalNames = new Set<string>()
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<number> {
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, string | boolean>): 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 <name>[,<name>...] 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 <name> [--skill <name> ...]`,
` 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<string, CommandHandler> = {
'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<string, CommandHandler> = {
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')
}

View File

@ -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 <names> 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> Key argument for this command',
limit: '--limit <n> Maximum number of rows to return',
local: '--local Target the current project instead of the global install',
skill: '--skill <name> Bundled skill to act on; repeat for several',
mode: '--mode <mode> Mode such as edit, diff, or both',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
modifiers: '--modifiers <chord> Modifier keys held only for this click',

View File

@ -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<string>(['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<typeof CodexCliCommandModule>()),
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<string>(['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 <name> [--skill <name> ...]',
' 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 <name> [--skill <name> ...]',
' 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<string>())
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<string>())
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<string>(['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<string>())
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<string>())
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 <names>')
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<typeof vi.spyOn>): string {
return spy.mock.calls.map((call) => String(call[0])).join('')
}
function createFakeChild(): EventEmitter {
return new EventEmitter()
}

View File

@ -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 <name>` 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 <name>]... [--all] [--agent <name>[,<name>]] ' +
'[--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 <repo> --skill <name> ...` 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 <name>[,<name>...] 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 <name>]... [--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 <names...>` 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'
]
}
]

View File

@ -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'
)
})

View File

@ -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(

View File

@ -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')

View File

@ -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([

View File

@ -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 <invalid>`, 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'])
})
})

View File

@ -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<TuiAgent, string | null>
/**
* 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<string>([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()
}