mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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`')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([
|
||||
'provision',
|
||||
'ready',
|
||||
'recipe-json',
|
||||
'references',
|
||||
'relations',
|
||||
'reinstall',
|
||||
'restore-window',
|
||||
|
||||
Reference in New Issue
Block a user