Files
orca/src/shared/skills-cli-agent-keys.test.ts
T
Sebastián Castañoandscastanoh21 676ef7fab8 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>
2026-07-30 11:20:29 -07:00

138 lines
3.5 KiB
TypeScript

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