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:
parent
e20554bfd7
commit
676ef7fab8
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
Loading…
Reference in New Issue