From 630bc6642c40e7e2b3fab10cd2aaca76a7d8cfac Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 5 Sep 2026 00:21:24 -0400 Subject: [PATCH] feat(cli): add skills get --reference and --references selectors An action gate names one reference, but --full was the only way to reach it and returned the kernel plus every reference. The selector serves one document, and the orchestration guide now teaches it with --full as the older-CLI fallback. --- .../orchestration-skill-guidance.test.mjs | 9 +- docs/site/content/docs/cli/reference.mdx | 2 + docs/site/content/docs/cli/skills.mdx | 4 + resources/skills/current-manifest.json | 12 +- resources/skills/snapshot-registry.json | 12 +- skill-guides/orchestration.md | 10 +- skill-stubs/orchestration.md | 5 +- skills/orchestration/SKILL.md | 5 +- src/cli/handlers/bundled-skill-guide-table.ts | 57 ++++++ src/cli/handlers/skill-guide-get.ts | 108 ++++++++++ src/cli/handlers/skills.ts | 74 +------ src/cli/help.ts | 9 +- src/cli/skills-command-flag-help.ts | 15 ++ src/cli/skills-reference-selector.test.ts | 186 ++++++++++++++++++ src/cli/skills.test.ts | 2 +- src/cli/specs/skills.test.ts | 12 ++ src/cli/specs/skills.ts | 13 +- src/cli/stdout-line.ts | 4 + src/shared/cli-argument-boundary.ts | 1 + 19 files changed, 445 insertions(+), 95 deletions(-) create mode 100644 src/cli/handlers/bundled-skill-guide-table.ts create mode 100644 src/cli/handlers/skill-guide-get.ts create mode 100644 src/cli/skills-command-flag-help.ts create mode 100644 src/cli/skills-reference-selector.test.ts create mode 100644 src/cli/stdout-line.ts diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 0b4b015177f..5c7593e3220 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -269,10 +269,13 @@ describe('owned orchestration references', () => { // Why the table and not every mention: prose may cite a reference the gate table already routes. expect(tableRoutes.sort()).toEqual(shipped) expect(kernel).toContain('ORCA skills get orchestration --full') - // Why: `skills get` has no per-reference selector, so the kernel describes the whole - // bundle it returns instead of promising selective loading. + // Why: the selector is the cheap path, so the kernel must teach it first and keep + // `--full` only as the fallback for a CLI build that predates it. expect(squash(kernel)).toContain( - 'It has no per-reference selector and returns this exact kernel and every reference from the same CLI build, so read only the named one' + 'run `ORCA skills get orchestration --reference references/.md`' + ) + expect(squash(kernel)).toContain( + 'If the CLI rejects `--reference`, run `ORCA skills get orchestration --full`' ) expect(squash(kernel)).toContain('If an older CLI rejects `--full`') }) diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index 0f24ca34192..1c9dbcf1676 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -290,6 +290,8 @@ List bundled guides, print a version-matched guide, or install/update hybrid ski ```bash orca skills list orca skills get orca-cli +orca skills get orchestration --references +orca skills get orchestration --reference recovery-and-cleanup orca skills get orchestration --full orca skills install --skill orca-cli --skill orchestration orca skills install --all --dry-run diff --git a/docs/site/content/docs/cli/skills.mdx b/docs/site/content/docs/cli/skills.mdx index 639b5099119..77ea47ce8ad 100644 --- a/docs/site/content/docs/cli/skills.mdx +++ b/docs/site/content/docs/cli/skills.mdx @@ -39,10 +39,14 @@ After `npx skills add`, agents see a short stub that says: ```bash orca skills list orca skills get orca-cli +orca skills get orchestration --references +orca skills get orchestration --reference recovery-and-cleanup orca skills get orchestration --full orca skills get orca-linear --json ``` +A guide's action gates name conditional references. `--reference ` prints one of them alone, so an agent pays for the kernel plus that document instead of the whole package; `--references` lists the names. The name may be bare (`recovery-and-cleanup`) or spelled as the guide writes it (`references/recovery-and-cleanup.md`). `--full` still prints the kernel plus every reference. + Add `--json` when an agent needs deterministic output for automation. `skills show` is an alias for `skills get`. ## Keep skills up to date diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index b647d01ab10..925b09f75fe 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -131,17 +131,17 @@ "name": "orchestration", "sourcePath": "skills/orchestration", "releaseRevision": 29, - "packageDigest": "003047e8b796d1648f13e2044f1a3686a106e7462a26e255f3b6430f43c07a1b", - "gitTreeSha": "561a732294249c1eb28642733d1b20b4ac5be7ea", + "packageDigest": "894d6f421cb96c2777e73055df867e2fdfca8dd05f0340d50a93cb33a8e85e3a", + "gitTreeSha": "da5b5c3f78634bbe12922e526ea227509faa9de0", "files": [ { "path": "SKILL.md", - "size": 4357, + "size": 4539, "executable": false, "classification": "text", - "exactSha256": "116e9d6ea4eb2e838c3280ddc62ef5e8cec22f014d95daaae52cd9e81f8673c1", - "textNormalizedSha256": "116e9d6ea4eb2e838c3280ddc62ef5e8cec22f014d95daaae52cd9e81f8673c1", - "identitySha256": "116e9d6ea4eb2e838c3280ddc62ef5e8cec22f014d95daaae52cd9e81f8673c1" + "exactSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954", + "textNormalizedSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954", + "identitySha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954" } ] } diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json index 94d6627baef..520c9250fb2 100644 --- a/resources/skills/snapshot-registry.json +++ b/resources/skills/snapshot-registry.json @@ -1046,17 +1046,17 @@ }, { "releaseRevision": 29, - "packageDigest": "003047e8b796d1648f13e2044f1a3686a106e7462a26e255f3b6430f43c07a1b", - "gitTreeSha": "561a732294249c1eb28642733d1b20b4ac5be7ea", + "packageDigest": "894d6f421cb96c2777e73055df867e2fdfca8dd05f0340d50a93cb33a8e85e3a", + "gitTreeSha": "da5b5c3f78634bbe12922e526ea227509faa9de0", "files": [ { "path": "SKILL.md", - "size": 4357, + "size": 4539, "executable": false, "classification": "text", - "exactSha256": "116e9d6ea4eb2e838c3280ddc62ef5e8cec22f014d95daaae52cd9e81f8673c1", - "textNormalizedSha256": "116e9d6ea4eb2e838c3280ddc62ef5e8cec22f014d95daaae52cd9e81f8673c1", - "identitySha256": "116e9d6ea4eb2e838c3280ddc62ef5e8cec22f014d95daaae52cd9e81f8673c1" + "exactSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954", + "textNormalizedSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954", + "identitySha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954" } ] } diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 673027a400a..3ffee4848eb 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -180,10 +180,12 @@ the coordinator turn until it returns none. ## Conditional references This compact guide is sufficient for the normal local loop. At an action gate -below, run `ORCA skills get orchestration --full` once. It has no per-reference -selector and returns this exact kernel and every reference from the same CLI -build, so read only the named one. If an older CLI rejects `--full`, keep this -kernel's safety floor, use that command's `--help`, and never guess newer flags. +below, run `ORCA skills get orchestration --reference references/.md` and +read only that document; `--references` lists the names. If the CLI rejects +`--reference`, run `ORCA skills get orchestration --full` once instead: it +returns this exact kernel and every reference, so read only the named one. If an +older CLI rejects `--full`, keep this kernel's safety floor, use that command's +`--help`, and never guess newer flags. | Action gate | Bundled reference | | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | diff --git a/skill-stubs/orchestration.md b/skill-stubs/orchestration.md index 2e225202ca6..54d78764062 100644 --- a/skill-stubs/orchestration.md +++ b/skill-stubs/orchestration.md @@ -40,7 +40,10 @@ ORCA skills get orchestration That prints the compact, version-matched guide for the exact binary that will handle your next commands. It covers the normal local coordinator loop. For a conditional action gate -such as remote placement, uncertain release recovery, or expanded DAG work, run +such as remote placement, uncertain release recovery, or expanded DAG work, load only the +reference that gate names with +`ORCA skills get orchestration --reference references/.md` +(`--references` lists the names). If that binary rejects `--reference`, run `ORCA skills get orchestration --full` and read the named bundled reference before acting. Don't guess subcommands or flags from memory or from a cached copy of this stub. They diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index f56b2fcda68..d10bc798419 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -57,7 +57,10 @@ ORCA skills get orchestration That prints the compact, version-matched guide for the exact binary that will handle your next commands. It covers the normal local coordinator loop. For a conditional action gate -such as remote placement, uncertain release recovery, or expanded DAG work, run +such as remote placement, uncertain release recovery, or expanded DAG work, load only the +reference that gate names with +`ORCA skills get orchestration --reference references/.md` +(`--references` lists the names). If that binary rejects `--reference`, run `ORCA skills get orchestration --full` and read the named bundled reference before acting. Don't guess subcommands or flags from memory or from a cached copy of this stub. They diff --git a/src/cli/handlers/bundled-skill-guide-table.ts b/src/cli/handlers/bundled-skill-guide-table.ts new file mode 100644 index 00000000000..efdf2ea003f --- /dev/null +++ b/src/cli/handlers/bundled-skill-guide-table.ts @@ -0,0 +1,57 @@ +import { RuntimeClientError } from '../runtime-client' + +export type BundledSkillGuideReference = { + name: string + markdown: string +} + +export type BundledSkillGuide = { + name: string + description: string + markdown: string + fullMarkdown: string + aliases: readonly string[] + references: readonly BundledSkillGuideReference[] +} + +function canonicalGuides(guides: readonly BundledSkillGuide[]): BundledSkillGuide[] { + return [...guides].sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0 + ) +} + +/** + * Load the embedded guide table in canonical order. Deferred because the table is + * large and unrelated CLI commands must not pay its module-load cost at startup. + */ +export async function loadCanonicalGuides(): Promise { + const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js') + return canonicalGuides(BUNDLED_SKILL_GUIDES) +} + +export function requireTopic( + flags: Map, + guides: BundledSkillGuide[] +): BundledSkillGuide { + const availableTopics = guides.map((guide) => guide.name).join(', ') + const topic = flags.get('topic') + if (typeof topic !== 'string' || topic.length === 0) { + throw new RuntimeClientError( + 'invalid_argument', + `Missing skill topic. Available topics: ${availableTopics}` + ) + } + // Why: installed stubs may retain an old topic forever, so aliases and canonical + // names share one lookup table instead of being treated as transient CLI aliases. + const guideByTopic = new Map( + guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide])) + ) + const guide = guideByTopic.get(topic) + if (!guide) { + throw new RuntimeClientError( + 'invalid_argument', + `Unknown skill topic "${topic}". Available topics: ${availableTopics}` + ) + } + return guide +} diff --git a/src/cli/handlers/skill-guide-get.ts b/src/cli/handlers/skill-guide-get.ts new file mode 100644 index 00000000000..8854f6072be --- /dev/null +++ b/src/cli/handlers/skill-guide-get.ts @@ -0,0 +1,108 @@ +import type { CommandHandler } from '../dispatch' +import { RuntimeClientError } from '../runtime-client' +import { writeStdoutLine } from '../stdout-line' +import { + loadCanonicalGuides, + requireTopic, + type BundledSkillGuide, + type BundledSkillGuideReference +} from './bundled-skill-guide-table' + +type GuideSelection = { full: boolean; reference: string | null; listReferences: boolean } + +// Why: the kernel's gate table names each document as `references/.md`, so that +// exact string must resolve as well as the bare name an agent is likely to retype. +function normalizeReferenceSelector(value: string): string { + return value + .trim() + .replace(/^references\//, '') + .replace(/\.md$/, '') +} + +function resolveSelection(flags: Map): GuideSelection { + const full = flags.has('full') + const listReferences = flags.get('references') === true + const requested = flags.get('reference') + const hasReference = flags.has('reference') + if (listReferences && full) { + throw new RuntimeClientError('invalid_argument', 'Use either --references or --full, not both.') + } + if (listReferences && hasReference) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --references or --reference, not both.' + ) + } + if (full && hasReference) { + throw new RuntimeClientError('invalid_argument', 'Use either --full or --reference, not both.') + } + if (hasReference && (typeof requested !== 'string' || requested.trim().length === 0)) { + throw new RuntimeClientError('invalid_argument', 'Missing required --reference') + } + return { + full, + reference: typeof requested === 'string' ? requested : null, + listReferences + } +} + +function requireReferences(guide: BundledSkillGuide): readonly BundledSkillGuideReference[] { + if (guide.references.length === 0) { + throw new RuntimeClientError( + 'invalid_argument', + `Guide "${guide.name}" has no bundled references.` + ) + } + return guide.references +} + +function requireReference(guide: BundledSkillGuide, requested: string): BundledSkillGuideReference { + const references = requireReferences(guide) + const selector = normalizeReferenceSelector(requested) + const match = references.find((reference) => reference.name === selector) + if (!match) { + const available = references.map((reference) => reference.name).join(', ') + throw new RuntimeClientError( + 'invalid_argument', + `Unknown reference "${requested}" for ${guide.name}. Available: ${available}` + ) + } + return match +} + +export const SKILL_GUIDE_GET_HANDLER: Record = { + 'skills get': async ({ flags, json }) => { + const selection = resolveSelection(flags) + const guides = await loadCanonicalGuides() + const guide = requireTopic(flags, guides) + + if (selection.listReferences) { + const names = requireReferences(guide).map((reference) => reference.name) + writeStdoutLine( + json ? JSON.stringify({ name: guide.name, references: names }, null, 2) : names.join('\n') + ) + return + } + + if (selection.reference !== null) { + const reference = requireReference(guide, selection.reference) + writeStdoutLine( + json + ? JSON.stringify( + { name: guide.name, reference: reference.name, markdown: reference.markdown }, + null, + 2 + ) + : reference.markdown + ) + return + } + + const markdown = selection.full ? guide.fullMarkdown : guide.markdown + writeStdoutLine( + json + ? JSON.stringify({ name: guide.name, full: selection.full, markdown }, null, 2) + : markdown + ) + } +} diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index fb0880617ad..1b068fc80b0 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -2,6 +2,9 @@ import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' import { getRepeatedStringFlag } from '../flags' +import { writeStdoutLine } from '../stdout-line' +import { loadCanonicalGuides, type BundledSkillGuide } from './bundled-skill-guide-table' +import { SKILL_GUIDE_GET_HANDLER } from './skill-guide-get' import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection' import { @@ -20,51 +23,6 @@ import { buildAgentFeatureSkillUpdateArgs } from '../../shared/agent-feature-install-commands' -type BundledSkillGuide = { - name: string - description: string - markdown: string - fullMarkdown: string - aliases: readonly string[] -} - -function canonicalGuides(guides: readonly BundledSkillGuide[]): BundledSkillGuide[] { - return [...guides].sort((left, right) => - left.name < right.name ? -1 : left.name > right.name ? 1 : 0 - ) -} - -function requireTopic( - flags: Map, - guides: BundledSkillGuide[] -): BundledSkillGuide { - const availableTopics = guides.map((guide) => guide.name).join(', ') - const topic = flags.get('topic') - if (typeof topic !== 'string' || topic.length === 0) { - throw new RuntimeClientError( - 'invalid_argument', - `Missing skill topic. Available topics: ${availableTopics}` - ) - } - // Why: installed stubs may retain an old topic forever, so aliases and canonical - // names share one lookup table instead of being treated as transient CLI aliases. - const guideByTopic = new Map( - guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide])) - ) - const guide = guideByTopic.get(topic) - if (!guide) { - throw new RuntimeClientError( - 'invalid_argument', - `Unknown skill topic "${topic}". Available topics: ${availableTopics}` - ) - } - return guide -} - -function writeStdout(value: string): void { - process.stdout.write(value.endsWith('\n') ? value : `${value}\n`) -} - function resolveSelectedSkillNames( flags: Map, guides: BundledSkillGuide[] @@ -251,14 +209,12 @@ function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[]) 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 guides = await loadCanonicalGuides() const skillNames = resolveSelectedSkillNames(flags, guides) if (skillNames.length === 0) { const names = guides.map((guide) => guide.name) - writeStdout( + writeStdoutLine( json ? JSON.stringify({ availableSkills: names }, null, 2) : formatSkillSelectionHelp(verb, names) @@ -286,7 +242,7 @@ function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler { const dryRun = flags.get('dry-run') === true if (dryRun) { - writeStdout( + writeStdoutLine( json ? JSON.stringify({ command, skills: skillNames, global, executed: false }, null, 2) : `${command}\n\nRerun without --dry-run to ${verb} now.` @@ -313,31 +269,19 @@ function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler { export const SKILL_HANDLERS: Record = { 'skills list': async ({ json }) => { - // Why: the embedded guide table is large, so unrelated CLI commands must not - // pay its module-load and parse cost during startup. - const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js') - const guides = canonicalGuides(BUNDLED_SKILL_GUIDES) // Why: generated registry order is not a user-facing contract, while stable // canonical sorting keeps agent-visible output reproducible across builds. - const topics = guides.map((guide) => ({ + const topics = (await loadCanonicalGuides()).map((guide) => ({ name: guide.name, description: guide.description.replace(/\s+/g, ' ').trim() })) - writeStdout( + writeStdoutLine( json ? JSON.stringify({ topics }, null, 2) : topics.map((topic) => `${topic.name}: ${topic.description}`).join('\n') ) }, - 'skills get': 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 guide = requireTopic(flags, guides) - const full = flags.has('full') - const markdown = full ? guide.fullMarkdown : guide.markdown - writeStdout(json ? JSON.stringify({ name: guide.name, full, markdown }, null, 2) : markdown) - }, + ...SKILL_GUIDE_GET_HANDLER, 'skills install': createSkillMutationHandler('install'), 'skills update': createSkillMutationHandler('update') } diff --git a/src/cli/help.ts b/src/cli/help.ts index daee319ddfc..227a5174cbd 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,6 +1,7 @@ import type { CommandSpec } from './args' import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args' import { unknownCommandData } from './command-suggestion' +import { formatSkillsCommandFlagHelp } from './skills-command-flag-help' import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary' import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary' @@ -72,8 +73,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string { function formatCommandFlagHelp(flag: string, commandPath: string[]): string { const command = commandPath.join(' ') - if (command === 'skills install' && flag === 'agent') { - return '--agent Comma-separated install targets; default is detected agents' + const skillsHelp = formatSkillsCommandFlagHelp(command, flag) + if (skillsHelp) { + return skillsHelp } if (command === 'terminal close' && flag === 'tab') { return '--tab Close the whole tab and wait for durable persistence' @@ -114,9 +116,6 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { if (command === 'orchestration worker-list' && flag === 'include-remote') { return '--include-remote Include connected-server worker observations' } - if (command === 'skills get' && flag === 'full') { - return '--full Print the full guide with bundled references' - } if (command === 'linear list-issues' && flag === 'workspace') { return '--workspace Connected Linear workspace id, or all' } diff --git a/src/cli/skills-command-flag-help.ts b/src/cli/skills-command-flag-help.ts new file mode 100644 index 00000000000..f1ecfd16f5f --- /dev/null +++ b/src/cli/skills-command-flag-help.ts @@ -0,0 +1,15 @@ +/** Per-flag help for the skills commands, kept out of the shared help chain it would crowd. */ +const SKILLS_FLAG_HELP: Record> = { + 'skills get': { + full: '--full Print the full guide with bundled references', + reference: '--reference Print one bundled reference by name', + references: '--references List the bundled reference names for a topic' + }, + 'skills install': { + agent: '--agent Comma-separated install targets; default is detected agents' + } +} + +export function formatSkillsCommandFlagHelp(command: string, flag: string): string | undefined { + return SKILLS_FLAG_HELP[command]?.[flag] +} diff --git a/src/cli/skills-reference-selector.test.ts b/src/cli/skills-reference-selector.test.ts new file mode 100644 index 00000000000..8b706f1da4a --- /dev/null +++ b/src/cli/skills-reference-selector.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' + +vi.mock('./bundled-skill-guides.js', () => ({ + BUNDLED_SKILL_GUIDES: [ + { + name: 'alpha', + description: 'Use when alpha work is needed.', + markdown: '# Alpha\n\nShort.\n', + fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n', + aliases: ['legacy-alpha'], + references: [ + { name: 'first-gate', markdown: '# First gate\n\nDo the first thing.\n' }, + { name: 'second-gate', markdown: '# Second gate\n\nDo the second thing.\n' } + ] + }, + { + name: 'zeta', + description: 'Use when zeta work is needed.', + markdown: '# Zeta\n', + fullMarkdown: '# Zeta\n', + aliases: [], + references: [] + } + ] +})) + +vi.mock('./runtime-client', async () => { + const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js') + class RuntimeClient { + constructor() { + throw new Error('skills get constructed a RuntimeClient') + } + } + return { + RuntimeClient, + RuntimeClientError, + RuntimeRpcFailureError, + serveOrcaApp: vi.fn(), + getDefaultUserDataPath: vi.fn(() => '/tmp/orca-user-data') + } +}) + +import { main } from './index' + +function stdoutText(spy: ReturnType): string { + return spy.mock.calls.map((call) => String(call[0])).join('') +} + +describe('orca skills get --reference', () => { + beforeEach(() => { + vi.restoreAllMocks() + process.exitCode = undefined + }) + + it('prints only the named reference, with no kernel and no header', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'get', 'alpha', '--reference', 'second-gate'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe('# Second gate\n\nDo the second thing.\n') + }) + + it('accepts the references/.md spelling the gate table prints', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'get', 'alpha', '--reference', 'references/first-gate.md'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe('# First gate\n\nDo the first thing.\n') + }) + + it('resolves a reference through a topic alias', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'get', 'legacy-alpha', '--reference', 'first-gate.md'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe('# First gate\n\nDo the first thing.\n') + }) + + it('gives --reference --json the canonical topic, reference name, and Markdown', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main( + ['skills', 'get', 'legacy-alpha', '--reference', 'references/first-gate.md', '--json'], + '/tmp/repo' + ) + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify( + { + name: 'alpha', + reference: 'first-gate', + markdown: '# First gate\n\nDo the first thing.\n' + }, + null, + 2 + )}\n` + ) + }) + + it('lists reference names for --references', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'get', 'alpha', '--references'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe('first-gate\nsecond-gate\n') + }) + + it('gives --references --json a stable schema', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'get', 'alpha', '--references', '--json'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify({ name: 'alpha', references: ['first-gate', 'second-gate'] }, null, 2)}\n` + ) + }) + + it('reports a topic that ships no references', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'get', 'zeta', '--references'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Guide "zeta" has no bundled references.') + }) + + it('names the available references for an unknown one', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'get', 'alpha', '--reference', 'nope'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown reference "nope" for alpha. Available: first-gate, second-gate' + ) + }) + + it('rejects --reference on a topic with no references', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'get', 'zeta', '--reference', 'first-gate'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Guide "zeta" has no bundled references.') + }) + + it('rejects --reference without a value', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'get', 'alpha', '--reference'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Missing required --reference') + }) + + it('rejects combining --full with --reference', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'get', 'alpha', '--full', '--reference', 'first-gate'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Use either --full or --reference, not both.') + }) + + it('rejects combining --references with --full or --reference', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'get', 'alpha', '--references', '--full'], '/tmp/repo') + await main(['skills', 'get', 'alpha', '--references', '--reference', 'first-gate'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenNthCalledWith(1, 'Use either --references or --full, not both.') + expect(errorSpy).toHaveBeenNthCalledWith(2, 'Use either --references or --reference, not both.') + }) + + it('still serves the kernel and the full package unchanged', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'get', 'alpha'], '/tmp/repo') + await main(['skills', 'get', 'alpha', '--full'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + '# Alpha\n\nShort.\n# Alpha\n\nShort.\n\n## References\n\nFull.\n' + ) + }) +}) diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 43ab8d69284..b30ddc100d5 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -213,7 +213,7 @@ describe('orca skills CLI', () => { await main(['--help'], '/tmp/repo') expect(String(logSpy.mock.calls[0]?.[0])).toContain( - 'Usage: orca skills get [--full] [--json]' + 'Usage: orca skills get [--full | --reference ] [--json]' ) expect(String(logSpy.mock.calls[1]?.[0])).toContain( 'Commands:\n installed List installed skill selectors' diff --git a/src/cli/specs/skills.test.ts b/src/cli/specs/skills.test.ts index c5c7221a0e4..e99a47c5783 100644 --- a/src/cli/specs/skills.test.ts +++ b/src/cli/specs/skills.test.ts @@ -20,6 +20,18 @@ describe('skill command specs', () => { expect(help).not.toContain('--full Include all supported V1 issue context') }) + it('documents the per-reference selector beside --full', () => { + const help = formatCommandHelp(spec('skills get')) + + expect(help).toContain('Usage: orca skills get [--full | --reference ] [--json]') + expect(help).toContain('--reference Print one bundled reference by name') + expect(help).toContain('--references List the bundled reference names for a topic') + expect(help).toContain('orca skills get orchestration --reference recovery-and-cleanup') + expect(effectiveAllowedFlags(spec('skills get'))).toEqual( + expect.arrayContaining(['reference', 'references']) + ) + }) + it('requires explicit selectors for sharing and exposes no bulk or path flag', () => { const flags = effectiveAllowedFlags(spec('skills share')) diff --git a/src/cli/specs/skills.ts b/src/cli/specs/skills.ts index 2a1955b7a93..05ca7893d6e 100644 --- a/src/cli/specs/skills.ts +++ b/src/cli/specs/skills.ts @@ -47,15 +47,22 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [ path: ['skills', 'get'], aliases: [['skills', 'show']], summary: 'Print a version-matched skill guide as Markdown', - usage: 'orca skills get [--full] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'topic', 'full'], + usage: 'orca skills get [--full | --reference ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'topic', 'full', 'reference', 'references'], positionalArgs: ['topic'], notes: [ 'Reads bundled guide content locally without contacting the Orca runtime.', 'Prints the compact guide by default. Use --full to print the full guide with bundled references when provided.', + 'Use --reference to print one bundled reference alone, which is what an action gate in the compact guide needs; --references lists the available names.', + 'A reference name may be given bare (recovery-and-cleanup) or as the guide spells it (references/recovery-and-cleanup.md).', 'Use --json for a deterministic object containing canonical topic metadata and content.' ], - examples: ['orca skills get orca-cli', 'orca skills get orchestration --full'] + examples: [ + 'orca skills get orca-cli', + 'orca skills get orchestration --full', + 'orca skills get orchestration --references', + 'orca skills get orchestration --reference recovery-and-cleanup' + ] }, { path: ['skills', 'install'], diff --git a/src/cli/stdout-line.ts b/src/cli/stdout-line.ts new file mode 100644 index 00000000000..ddafe075a33 --- /dev/null +++ b/src/cli/stdout-line.ts @@ -0,0 +1,4 @@ +/** Write one newline-terminated payload to stdout without doubling an existing newline. */ +export function writeStdoutLine(value: string): void { + process.stdout.write(value.endsWith('\n') ? value : `${value}\n`) +} diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index bdbc5b25989..088f6ff0e76 100644 --- a/src/shared/cli-argument-boundary.ts +++ b/src/shared/cli-argument-boundary.ts @@ -31,6 +31,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([ 'provision', 'ready', 'recipe-json', + 'references', 'relations', 'reinstall', 'restore-window',