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.
This commit is contained in:
Jinwoo-H
2026-09-05 00:21:24 -04:00
parent 4748b24a03
commit 630bc6642c
19 changed files with 445 additions and 95 deletions
@@ -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/<file>.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`')
})
+2
View File
@@ -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
+4
View File
@@ -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 <name>` 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
+6 -6
View File
@@ -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"
}
]
}
+6 -6
View File
@@ -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"
}
]
}
+6 -4
View File
@@ -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/<file>.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 |
| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
+4 -1
View File
@@ -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/<file>.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
+4 -1
View File
@@ -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/<file>.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
@@ -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<BundledSkillGuide[]> {
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
return canonicalGuides(BUNDLED_SKILL_GUIDES)
}
export function requireTopic(
flags: Map<string, string | boolean>,
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<string, BundledSkillGuide>(
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
}
+108
View File
@@ -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/<file>.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<string, string | boolean>): 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<string, CommandHandler> = {
'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
)
}
}
+9 -65
View File
@@ -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<string, string | boolean>,
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<string, BundledSkillGuide>(
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<string, string | boolean>,
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<string, CommandHandler> = {
'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')
}
+4 -5
View File
@@ -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 <names> 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 <id|all> Connected Linear workspace id, or all'
}
+15
View File
@@ -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<string, Record<string, string>> = {
'skills get': {
full: '--full Print the full guide with bundled references',
reference: '--reference <name> Print one bundled reference by name',
references: '--references List the bundled reference names for a topic'
},
'skills install': {
agent: '--agent <names> Comma-separated install targets; default is detected agents'
}
}
export function formatSkillsCommandFlagHelp(command: string, flag: string): string | undefined {
return SKILLS_FLAG_HELP[command]?.[flag]
}
+186
View File
@@ -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<typeof vi.spyOn>): 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/<file>.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'
)
})
})
+1 -1
View File
@@ -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 <topic> [--full] [--json]'
'Usage: orca skills get <topic> [--full | --reference <name>] [--json]'
)
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
'Commands:\n installed List installed skill selectors'
+12
View File
@@ -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 <topic> [--full | --reference <name>] [--json]')
expect(help).toContain('--reference <name> 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'))
+10 -3
View File
@@ -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 <topic> [--full] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'topic', 'full'],
usage: 'orca skills get <topic> [--full | --reference <name>] [--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 <name> 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'],
+4
View File
@@ -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`)
}
+1
View File
@@ -31,6 +31,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([
'provision',
'ready',
'recipe-json',
'references',
'relations',
'reinstall',
'restore-window',