mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Show Orca publishing guidance only when the host has it on (STA-12225) (#34547)
This commit is contained in:
@@ -124,31 +124,10 @@ function constantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
|
||||
}
|
||||
|
||||
function fullConstantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_FULL_MARKDOWN`
|
||||
}
|
||||
|
||||
function referenceConstantName(guideName, referenceName) {
|
||||
return `${`${guideName}_${referenceName}`.replace(/-/g, '_').toUpperCase()}_REFERENCE_MARKDOWN`
|
||||
}
|
||||
|
||||
function composeFullMarkdown(markdown, references) {
|
||||
if (references.length === 0) {
|
||||
return markdown
|
||||
}
|
||||
const packageHeader =
|
||||
'\n\n---\n\n# Bundled references\n\n' +
|
||||
'These references belong to the version-matched guide above. Read only the documents ' +
|
||||
'named by its action gates.\n'
|
||||
const documents = references
|
||||
.map(
|
||||
({ relativePath, markdown: referenceMarkdown }) =>
|
||||
`\n<!-- bundled-reference: ${relativePath} -->\n\n${referenceMarkdown.trimEnd()}\n`
|
||||
)
|
||||
.join('')
|
||||
return `${markdown.trimEnd()}${packageHeader}${documents}`
|
||||
}
|
||||
|
||||
function serializeMarkdown(markdown) {
|
||||
const lines = markdown.split('\n').map((line) => ` ${JSON.stringify(line)},`)
|
||||
return `[\n${lines.join('\n')}\n].join('\\n')`
|
||||
@@ -167,11 +146,6 @@ function serializeEmbeddedModule(guides) {
|
||||
const constants = [
|
||||
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${serializeMarkdown(guide.markdown)}`
|
||||
]
|
||||
if (guide.fullMarkdown !== guide.markdown) {
|
||||
constants.push(
|
||||
`// oxfmt-ignore\nconst ${fullConstantName(guide.name)} = ${serializeMarkdown(guide.fullMarkdown)}`
|
||||
)
|
||||
}
|
||||
for (const reference of guide.references) {
|
||||
constants.push(
|
||||
`// oxfmt-ignore\nconst ${referenceConstantName(guide.name, reference.name)} = ${serializeMarkdown(reference.markdown)}`
|
||||
@@ -194,7 +168,6 @@ function serializeEmbeddedModule(guides) {
|
||||
` name: ${JSON.stringify(guide.name)},`,
|
||||
` description: ${JSON.stringify(guide.description)},`,
|
||||
` markdown: ${markdownConstant},`,
|
||||
` fullMarkdown: ${guide.fullMarkdown === guide.markdown ? markdownConstant : fullConstantName(guide.name)},`,
|
||||
` aliases: ${JSON.stringify(guide.aliases)},`,
|
||||
` references: [${referenceEntries}]`,
|
||||
' }'
|
||||
@@ -202,7 +175,7 @@ function serializeEmbeddedModule(guides) {
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuideReference = {\n readonly name: string\n readonly markdown: string\n}\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n readonly references: readonly BundledSkillGuideReference[]\n}\n\n${markdownConstants}\n\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuideReference = {\n readonly name: string\n readonly markdown: string\n}\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly aliases: readonly string[]\n readonly references: readonly BundledSkillGuideReference[]\n}\n\n${markdownConstants}\n\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
}
|
||||
|
||||
async function readGuideReferences(repoRoot, guideName) {
|
||||
@@ -231,7 +204,7 @@ async function readGuideReferences(repoRoot, guideName) {
|
||||
if (!markdown.trim()) {
|
||||
throw new Error(`Guide reference is empty: ${toPosixRelativePath(repoRoot, sourcePath)}`)
|
||||
}
|
||||
return { name: entry.name.slice(0, -3), relativePath: `references/${entry.name}`, markdown }
|
||||
return { name: entry.name.slice(0, -3), markdown }
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -319,13 +292,8 @@ async function buildArtifacts(repoRoot = REPO_ROOT) {
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
markdown,
|
||||
fullMarkdown: composeFullMarkdown(markdown, references),
|
||||
aliases,
|
||||
// Per-reference requests need individual identity erased by fullMarkdown concatenation.
|
||||
references: references.map(({ name: referenceName, markdown: referenceMarkdown }) => ({
|
||||
name: referenceName,
|
||||
markdown: referenceMarkdown
|
||||
}))
|
||||
references
|
||||
})
|
||||
const stubPath = path.join(repoRoot, 'skill-stubs', `${name}.md`)
|
||||
const content = stubTopics.has(name)
|
||||
@@ -398,7 +366,6 @@ export {
|
||||
STUB_TOPICS,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
composeFullMarkdown,
|
||||
composeStubProjection,
|
||||
frontmatterBlock,
|
||||
normalizeMarkdown,
|
||||
|
||||
@@ -35,7 +35,7 @@ const GUIDE_REFERENCES = {
|
||||
'recovery-and-cleanup.md',
|
||||
'worker-contract.md'
|
||||
],
|
||||
'orca-cli': ['automations.md', 'browser.md', 'publishing.md'],
|
||||
'orca-cli': ['artifacts.md', 'automations.md', 'browser.md', 'skill-sharing.md'],
|
||||
'orca-per-workspace-env': [
|
||||
'docker-ssh.md',
|
||||
'failure-modes.md',
|
||||
@@ -191,7 +191,6 @@ describe('bundled skill guide generator', () => {
|
||||
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
|
||||
const references = GUIDE_REFERENCES[guide.name]
|
||||
if (!references) {
|
||||
expect(guide.fullMarkdown).toBe(source)
|
||||
expect(guide.references).toEqual([])
|
||||
continue
|
||||
}
|
||||
@@ -215,21 +214,6 @@ describe('bundled skill guide generator', () => {
|
||||
)
|
||||
)
|
||||
}
|
||||
expect(guide.fullMarkdown).not.toBe(guide.markdown)
|
||||
expect(guide.fullMarkdown.length).toBeGreaterThan(guide.markdown.length)
|
||||
expect(guide.fullMarkdown.startsWith(source.trimEnd())).toBe(true)
|
||||
for (const reference of references) {
|
||||
const marker = `<!-- bundled-reference: references/${reference} -->`
|
||||
expect(guide.fullMarkdown.split(marker)).toHaveLength(2)
|
||||
expect(guide.fullMarkdown).toContain(
|
||||
normalizeMarkdown(
|
||||
await readFile(
|
||||
path.join(projectDir, 'skill-guides', guide.name, 'references', reference),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -52,16 +52,16 @@
|
||||
{
|
||||
"name": "orca-cli",
|
||||
"sourcePath": "skills/orca-cli",
|
||||
"packageDigest": "0736bcbbb69ed18f9a36a58ad2eda47b6db55b30509953cf5ba5f0032058c535",
|
||||
"packageDigest": "b461950523a5d2a7be9c35d9029190204cf4c5f082d49edb55090dda234e924e",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2372,
|
||||
"size": 2312,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a",
|
||||
"textNormalizedSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a",
|
||||
"identitySha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a"
|
||||
"exactSha256": "e9759aea664f54561946eeb40a6328031330860cda0817f2c2aa2cf529d5c1b3",
|
||||
"textNormalizedSha256": "e9759aea664f54561946eeb40a6328031330860cda0817f2c2aa2cf529d5c1b3",
|
||||
"identitySha256": "e9759aea664f54561946eeb40a6328031330860cda0817f2c2aa2cf529d5c1b3"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+1
@@ -70,6 +70,7 @@
|
||||
"b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be",
|
||||
"baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56",
|
||||
"cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"e9759aea664f54561946eeb40a6328031330860cda0817f2c2aa2cf529d5c1b3",
|
||||
"ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4",
|
||||
"ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
|
||||
"f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137"
|
||||
|
||||
+37
-23
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: orca-cli
|
||||
description: >-
|
||||
Operate Orca-managed worktrees, folder contexts, terminals, repos, automations, artifacts,
|
||||
skill sharing, worktree comments, and Orca's embedded browser through the `orca` CLI. Use
|
||||
when the user says "$orca-cli", "Orca worktree", "child worktree", "spawn codex/claude in a
|
||||
worktree", "read/wait/send Orca terminal", "handoff" / "handover" / "give this to another
|
||||
agent", "Orca browser", "orca artifacts", or "share skills". Prefer it over raw git
|
||||
worktree, ad hoc PTYs, or Computer Use when Orca state is involved. Use Computer Use only
|
||||
when a visible window needs GUI control that a CLI, filesystem, or API cannot do.
|
||||
Operate Orca-managed worktrees, folder contexts, terminals, repos, automations, worktree
|
||||
comments, and Orca's embedded browser through the `orca` CLI. Use when the user says
|
||||
"$orca-cli", "Orca worktree", "child worktree", "spawn codex/claude in a worktree",
|
||||
"read/wait/send Orca terminal", "handoff" / "handover" / "give this to another agent", or
|
||||
"Orca browser". Prefer it over raw git worktree, ad hoc PTYs, or Computer Use when Orca
|
||||
state is involved. Use Computer Use only when a visible window needs GUI control that a
|
||||
CLI, filesystem, or API cannot do.
|
||||
---
|
||||
|
||||
# Orca CLI
|
||||
@@ -239,26 +239,41 @@ Terminal rules:
|
||||
- For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`.
|
||||
- `--direction vertical` splits left/right. `--direction horizontal` splits top/bottom.
|
||||
|
||||
<!-- host-capability: artifact-publishing -->
|
||||
|
||||
## Artifacts
|
||||
|
||||
Artifacts publish HTML or Markdown files through the signed-in Orca account. Anyone can view
|
||||
the share URL; creating, listing, updating, and deleting need the active profile signed in.
|
||||
Artifacts publish HTML or Markdown files as public links through the signed-in Orca account.
|
||||
Use them only when the user explicitly asks to publish through Orca. A generic request for a
|
||||
public link, page, or URL does not select Orca, and a provider the user names always wins.
|
||||
|
||||
**Publishing is off by default and only a human can turn it on.** `share` and `update` need a
|
||||
device-wide capability the user grants in the desktop app under Settings → Artifacts ("Allow
|
||||
publishing public artifact links"). It applies to every caller on the device, agent or human.
|
||||
There is no CLI or RPC way to grant it. `list`, `unshare`, and `delete` are never gated, so old
|
||||
links stay auditable and revocable.
|
||||
**Only a human can turn publishing on or off.** `share` and `update` need the device-wide
|
||||
setting under Settings → Artifacts ("Allow publishing public artifact links"); there is no
|
||||
CLI or RPC way to grant it. `list`, `unshare`, and `delete` are never gated, so old links
|
||||
stay auditable and revocable.
|
||||
|
||||
A denied share fails with `artifact_sharing_disabled` before any upload. Do not retry; the
|
||||
answer will not change until a human acts. Tell the user to turn the setting on and re-run, or
|
||||
deliver the file locally if they decline.
|
||||
A denied share fails with `artifact_sharing_disabled` before any upload. Do not retry. Tell
|
||||
the user publishing is off; mention the setting only if they asked for Orca by name, and
|
||||
otherwise deliver the file locally or through the provider they chose.
|
||||
|
||||
On a headless Orca server, artifact and skill-sharing commands fail with
|
||||
`sharing_unavailable_on_host`: no Orca cloud sign-in exists there. Do not retry or ask the
|
||||
user to change settings; tell them to share from the Orca desktop app instead.
|
||||
On a headless Orca server, artifact commands fail with `sharing_unavailable_on_host`: no
|
||||
Orca cloud sign-in exists there. Do not retry or ask the user to change settings; tell them
|
||||
to share from the Orca desktop app instead.
|
||||
|
||||
The `artifacts` commands, and the separate default-off permission for publishing installed skills, are in `references/publishing.md`. Load it before publishing either kind of link; a skill folder can hold scripts, configuration, or credentials.
|
||||
The `artifacts` commands are in `references/artifacts.md`. Load it before publishing.
|
||||
|
||||
<!-- /host-capability -->
|
||||
|
||||
<!-- host-capability: skill-publishing -->
|
||||
|
||||
## Skill Sharing
|
||||
|
||||
Publish installed skills only when the user explicitly asks to share skills through Orca,
|
||||
and only the skills they named: a skill folder can hold scripts, configuration, or
|
||||
credentials. The commands and their permission are in `references/skill-sharing.md`. Load
|
||||
it before publishing.
|
||||
|
||||
<!-- /host-capability -->
|
||||
|
||||
## Built-In Browser
|
||||
|
||||
@@ -298,11 +313,10 @@ Search rules:
|
||||
|
||||
## Conditional references
|
||||
|
||||
This guide covers worktrees, terminals, and handoffs on its own. At a gate below, run `ORCA skills get orca-cli --reference references/<file>.md` and read only that document; `--references` lists the names. If the CLI rejects `--reference`, run `ORCA skills get orca-cli --full` once instead: it returns this guide plus every reference from the same CLI build, so read only the named one. If `--full` is rejected too, the CLI predates bundled references: use `ORCA <command> --help`, keep the rules above, and do not guess flags.
|
||||
This guide covers worktrees, terminals, and handoffs on its own. At a gate below, run `ORCA skills get orca-cli --reference references/<file>.md` and read only that document; `--references` lists the names. If the CLI rejects `--reference`, run `ORCA skills get orca-cli --full` once instead: it returns this guide plus every reference this CLI build serves on this host, so read only the named one. If `--full` is rejected too, the CLI predates bundled references: use `ORCA <command> --help`, keep the rules above, and do not guess flags.
|
||||
|
||||
| Action gate | Reference |
|
||||
| --------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| Driving Orca's embedded browser: navigation, snapshots, refs, tabs, concurrent pages, or `browser_*` recoveries | `references/browser.md` |
|
||||
| Creating, editing, running, or inspecting scheduled automations | `references/automations.md` |
|
||||
| Publishing or revoking an artifact link, or publishing installed skills | `references/publishing.md` |
|
||||
| Mobile emulator taps, gestures, typing, buttons, camera, or permissions | invoke the `orca-emulator` skill |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- host-capability: artifact-publishing -->
|
||||
|
||||
# Artifact commands
|
||||
|
||||
The publish gate, explicit-intent rule, and denial recovery are in the guide body.
|
||||
|
||||
```text
|
||||
ORCA artifacts share <file> --json
|
||||
ORCA artifacts update <file> --json
|
||||
ORCA artifacts unshare <file> --json
|
||||
ORCA artifacts list [--cursor <cursor>] --json
|
||||
ORCA artifacts delete <id> --json
|
||||
```
|
||||
|
||||
- `share`, `update`, and `unshare` accept `.html`, `.htm`, `.md`, and `.markdown` files.
|
||||
- `share` saves the returned edit token in the active Orca profile and never includes it
|
||||
in CLI output. `update` and `unshare` look up that record by the resolved local file
|
||||
path, so use the same path and Orca profile that originally shared the file.
|
||||
- `list` returns one page of artifacts owned by the signed-in account. If JSON output has
|
||||
`nextCursor`, pass it back with `--cursor <cursor>`. `delete <id>` deletes an account-owned
|
||||
artifact by the id returned from `list`; it does not need the original local file or its
|
||||
edit-token record.
|
||||
- Relative HTML assets are not uploaded. Share a self-contained HTML file or use absolute
|
||||
asset URLs.
|
||||
- If an upload exceeds the CLI transport limit, use the browser upload page as directed
|
||||
by the error.
|
||||
- For local or staging development, `--api-url <url>` overrides the artifact service;
|
||||
`ORCA_ARTIFACTS_API_URL` provides the same override for the session.
|
||||
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
|
||||
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
|
||||
|
||||
<!-- /host-capability -->
|
||||
+6
-33
@@ -1,35 +1,6 @@
|
||||
# Artifact and skill publishing commands
|
||||
<!-- host-capability: skill-publishing -->
|
||||
|
||||
The publish gate and its recovery are in the guide body. This is the command surface behind it.
|
||||
|
||||
## Artifacts
|
||||
|
||||
```text
|
||||
ORCA artifacts share <file> --json
|
||||
ORCA artifacts update <file> --json
|
||||
ORCA artifacts unshare <file> --json
|
||||
ORCA artifacts list [--cursor <cursor>] --json
|
||||
ORCA artifacts delete <id> --json
|
||||
```
|
||||
|
||||
- `share`, `update`, and `unshare` accept `.html`, `.htm`, `.md`, and `.markdown` files.
|
||||
- `share` saves the returned edit token in the active Orca profile and never includes it
|
||||
in CLI output. `update` and `unshare` look up that record by the resolved local file
|
||||
path, so use the same path and Orca profile that originally shared the file.
|
||||
- `list` returns one page of artifacts owned by the signed-in account. If JSON output has
|
||||
`nextCursor`, pass it back with `--cursor <cursor>`. `delete <id>` deletes an account-owned
|
||||
artifact by the id returned from `list`; it does not need the original local file or its
|
||||
edit-token record.
|
||||
- Relative HTML assets are not uploaded. Share a self-contained HTML file or use absolute
|
||||
asset URLs.
|
||||
- If an upload exceeds the CLI transport limit, use the browser upload page as directed
|
||||
by the error.
|
||||
- For local or staging development, `--api-url <url>` overrides the artifact service;
|
||||
`ORCA_ARTIFACTS_API_URL` provides the same override for the session.
|
||||
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
|
||||
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
|
||||
|
||||
## Skill sharing
|
||||
# Skill sharing commands
|
||||
|
||||
Agents can publish one or more installed skills behind one unlisted link through the
|
||||
signed-in Orca account. The user must first grant the separate, default-off permission in
|
||||
@@ -51,8 +22,8 @@ ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <nam
|
||||
intentionally unsupported; name every skill the user asked to publish.
|
||||
- Skill folders can contain scripts, configuration, or credentials. The permission is
|
||||
authority, not intent: publish only the skills the user named and never widen the set.
|
||||
- A denied command fails with `agent_skill_sharing_disabled`. Do not retry; ask the user to
|
||||
enable the switch in the desktop app if they want this action.
|
||||
- A denied command fails with `agent_skill_sharing_disabled`. Do not retry; the user turned
|
||||
the permission off. Mention the switch only if they asked to share skills through Orca.
|
||||
- On a headless Orca server, skill and artifact sharing fail with `sharing_unavailable_on_host`.
|
||||
Do not retry; the host has no Orca cloud sign-in, so the user must share from the desktop app.
|
||||
- Orca stages one agent-published bundle at a time per host. If another publish is active,
|
||||
@@ -65,3 +36,5 @@ ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <nam
|
||||
wrong filesystem.
|
||||
- The JSON result contains the unlisted URL and public share/package/version IDs. It never
|
||||
includes cloud authentication tokens.
|
||||
|
||||
<!-- /host-capability -->
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: orca-cli
|
||||
description: >-
|
||||
Operate Orca-managed worktrees, folder contexts, terminals, repos, automations, artifacts,
|
||||
skill sharing, worktree comments, and Orca's embedded browser through the `orca` CLI. Use
|
||||
when the user says "$orca-cli", "Orca worktree", "child worktree", "spawn codex/claude in a
|
||||
worktree", "read/wait/send Orca terminal", "handoff" / "handover" / "give this to another
|
||||
agent", "Orca browser", "orca artifacts", or "share skills". Prefer it over raw git
|
||||
worktree, ad hoc PTYs, or Computer Use when Orca state is involved. Use Computer Use only
|
||||
when a visible window needs GUI control that a CLI, filesystem, or API cannot do.
|
||||
Operate Orca-managed worktrees, folder contexts, terminals, repos, automations, worktree
|
||||
comments, and Orca's embedded browser through the `orca` CLI. Use when the user says
|
||||
"$orca-cli", "Orca worktree", "child worktree", "spawn codex/claude in a worktree",
|
||||
"read/wait/send Orca terminal", "handoff" / "handover" / "give this to another agent", or
|
||||
"Orca browser". Prefer it over raw git worktree, ad hoc PTYs, or Computer Use when Orca
|
||||
state is involved. Use Computer Use only when a visible window needs GUI control that a
|
||||
CLI, filesystem, or API cannot do.
|
||||
---
|
||||
|
||||
# Orca CLI
|
||||
|
||||
+51
-2258
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ export type BundledSkillGuide = {
|
||||
name: string
|
||||
description: string
|
||||
markdown: string
|
||||
fullMarkdown: string
|
||||
aliases: readonly string[]
|
||||
references: readonly BundledSkillGuideReference[]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { writeStdoutLine } from '../stdout-line'
|
||||
import {
|
||||
@@ -7,8 +7,26 @@ import {
|
||||
type BundledSkillGuide,
|
||||
type BundledSkillGuideReference
|
||||
} from './bundled-skill-guide-table'
|
||||
import {
|
||||
filterHostCapabilityBlocks,
|
||||
hasHostCapabilityBlocks,
|
||||
readEnabledHostCapabilities
|
||||
} from './skill-guide-host-gate'
|
||||
|
||||
type GuideSelection = { full: boolean; reference: string | null; listReferences: boolean }
|
||||
type HostGate = (markdown: string) => Promise<string>
|
||||
|
||||
// Query the host at most once, and only when the served text has host-gated blocks.
|
||||
function createHostGate(ctx: HandlerContext): HostGate {
|
||||
let enabled: ReturnType<typeof readEnabledHostCapabilities> | undefined
|
||||
return async (markdown) => {
|
||||
if (!hasHostCapabilityBlocks(markdown)) {
|
||||
return markdown
|
||||
}
|
||||
enabled ??= readEnabledHostCapabilities(ctx)
|
||||
return filterHostCapabilityBlocks(markdown, await enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Accept references/<file>.md exactly as published in the kernel's gate table.
|
||||
function normalizeReferenceSelector(value: string): string {
|
||||
@@ -55,28 +73,77 @@ function requireReferences(guide: BundledSkillGuide): readonly BundledSkillGuide
|
||||
return guide.references
|
||||
}
|
||||
|
||||
function requireReference(guide: BundledSkillGuide, requested: string): BundledSkillGuideReference {
|
||||
const references = requireReferences(guide)
|
||||
// A reference whose blocks are all withheld is not served at all.
|
||||
async function servedReferences(
|
||||
guide: BundledSkillGuide,
|
||||
gate: HostGate
|
||||
): Promise<BundledSkillGuideReference[]> {
|
||||
const references = await Promise.all(
|
||||
requireReferences(guide).map(async ({ name, markdown }) => ({
|
||||
name,
|
||||
markdown: await gate(markdown)
|
||||
}))
|
||||
)
|
||||
return references.filter((reference) => reference.markdown.trim().length > 0)
|
||||
}
|
||||
|
||||
async function requireReference(
|
||||
guide: BundledSkillGuide,
|
||||
requested: string,
|
||||
gate: HostGate
|
||||
): Promise<BundledSkillGuideReference> {
|
||||
const selector = normalizeReferenceSelector(requested)
|
||||
const match = references.find((reference) => reference.name === selector)
|
||||
const match = requireReferences(guide).find((reference) => reference.name === selector)
|
||||
if (!match) {
|
||||
const available = references.map((reference) => reference.name).join(', ')
|
||||
const available = (await servedReferences(guide, gate))
|
||||
.map((reference) => reference.name)
|
||||
.join(', ')
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown reference "${requested}" for ${guide.name}. Available: ${available}`
|
||||
)
|
||||
}
|
||||
return match
|
||||
const markdown = await gate(match.markdown)
|
||||
if (markdown.trim().length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Reference "${requested}" for ${guide.name} is unavailable: the Orca feature it covers is turned off on this host.`
|
||||
)
|
||||
}
|
||||
return { name: match.name, markdown }
|
||||
}
|
||||
|
||||
// Composed at serve time so withheld references leave no trace in --full.
|
||||
function composeFullGuide(
|
||||
markdown: string,
|
||||
references: readonly BundledSkillGuideReference[]
|
||||
): string {
|
||||
if (references.length === 0) {
|
||||
return markdown
|
||||
}
|
||||
const header =
|
||||
'\n\n---\n\n# Bundled references\n\n' +
|
||||
'These references belong to the version-matched guide above. Read only the documents ' +
|
||||
'named by its action gates.\n'
|
||||
const documents = references
|
||||
.map(
|
||||
(reference) =>
|
||||
`\n<!-- bundled-reference: references/${reference.name}.md -->\n\n${reference.markdown.trimEnd()}\n`
|
||||
)
|
||||
.join('')
|
||||
return `${markdown.trimEnd()}${header}${documents}`
|
||||
}
|
||||
|
||||
export const SKILL_GUIDE_GET_HANDLER: Record<string, CommandHandler> = {
|
||||
'skills get': async ({ flags, json }) => {
|
||||
'skills get': async (ctx) => {
|
||||
const { flags, json } = ctx
|
||||
const selection = resolveSelection(flags)
|
||||
const guides = await loadCanonicalGuides()
|
||||
const guide = requireTopic(flags, guides)
|
||||
const gate = createHostGate(ctx)
|
||||
|
||||
if (selection.listReferences) {
|
||||
const names = requireReferences(guide).map((reference) => reference.name)
|
||||
const names = (await servedReferences(guide, gate)).map((reference) => reference.name)
|
||||
writeStdoutLine(
|
||||
json ? JSON.stringify({ name: guide.name, references: names }, null, 2) : names.join('\n')
|
||||
)
|
||||
@@ -84,7 +151,7 @@ export const SKILL_GUIDE_GET_HANDLER: Record<string, CommandHandler> = {
|
||||
}
|
||||
|
||||
if (selection.reference !== null) {
|
||||
const reference = requireReference(guide, selection.reference)
|
||||
const reference = await requireReference(guide, selection.reference, gate)
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify(
|
||||
@@ -97,7 +164,11 @@ export const SKILL_GUIDE_GET_HANDLER: Record<string, CommandHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
const markdown = selection.full ? guide.fullMarkdown : guide.markdown
|
||||
const kernel = await gate(guide.markdown)
|
||||
const markdown =
|
||||
selection.full && guide.references.length > 0
|
||||
? composeFullGuide(kernel, await servedReferences(guide, gate))
|
||||
: kernel
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ name: guide.name, full: selection.full, markdown }, null, 2)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Settings = { artifactSharingEnabled?: boolean; agentSkillSharingEnabled?: boolean }
|
||||
type Host = { settings: Settings | Error; isRemote?: boolean } | 'no-access'
|
||||
|
||||
const { hostState } = vi.hoisted(() => {
|
||||
const state: { host: Host; calls: number } = { host: 'no-access', calls: 0 }
|
||||
return { hostState: state }
|
||||
})
|
||||
|
||||
vi.mock('../runtime-client', async () => {
|
||||
// Error formatting uses instanceof, so the mock must preserve the production classes.
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
|
||||
|
||||
class RuntimeClient {
|
||||
readonly isRemote: boolean
|
||||
constructor() {
|
||||
if (hostState.host === 'no-access') {
|
||||
throw new Error('skills get contacted the host')
|
||||
}
|
||||
this.isRemote = hostState.host.isRemote ?? false
|
||||
}
|
||||
async call(method: string): Promise<{ result: { settings: Settings } }> {
|
||||
hostState.calls += 1
|
||||
if (hostState.host === 'no-access' || method !== 'settings.get') {
|
||||
throw new Error(`unexpected ${method}`)
|
||||
}
|
||||
if (hostState.host.settings instanceof Error) {
|
||||
throw hostState.host.settings
|
||||
}
|
||||
return { result: { settings: hostState.host.settings } }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
serveOrcaApp: vi.fn(),
|
||||
getDefaultUserDataPath: vi.fn(() => '/tmp/orca-user-data')
|
||||
}
|
||||
})
|
||||
|
||||
import { BUNDLED_SKILL_GUIDES } from '../bundled-skill-guides'
|
||||
import { main } from '../index'
|
||||
import { GUIDE_HOST_CAPABILITIES } from './skill-guide-host-gate'
|
||||
|
||||
const PUBLISHING_TEXT = /artifacts share|skills share|## Artifacts|## Skill Sharing/
|
||||
|
||||
async function skillsGet(args: string[], host: Host): Promise<{ stdout: string; stderr: string }> {
|
||||
hostState.host = host
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
stdout += String(chunk)
|
||||
return true
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation((message) => {
|
||||
stderr += String(message)
|
||||
})
|
||||
await main(['skills', 'get', 'orca-cli', ...args], '/tmp/repo')
|
||||
vi.restoreAllMocks()
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
describe('skills get host-capability gating', () => {
|
||||
beforeEach(() => {
|
||||
hostState.calls = 0
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
it('only ships closed, unnested blocks that name a known capability', () => {
|
||||
const documents = BUNDLED_SKILL_GUIDES.flatMap((guide) => [
|
||||
guide.markdown,
|
||||
...guide.references.map((reference) => reference.markdown)
|
||||
])
|
||||
for (const markdown of documents) {
|
||||
let open: string | null = null
|
||||
for (const [, closing, name] of markdown.matchAll(
|
||||
/<!-- (\/?)host-capability(?:: ([a-z-]+))? -->/g
|
||||
)) {
|
||||
if (closing) {
|
||||
expect(open).not.toBeNull()
|
||||
open = null
|
||||
continue
|
||||
}
|
||||
expect(open).toBeNull()
|
||||
expect(GUIDE_HOST_CAPABILITIES).toContain(name)
|
||||
open = name
|
||||
}
|
||||
expect(open).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it.each<[string, Host]>([
|
||||
['publishing is off', { settings: { artifactSharingEnabled: false } }],
|
||||
['the host omits the settings (headless or older)', { settings: {} }],
|
||||
['the host is unreachable', { settings: new Error('Orca is not running') }]
|
||||
])('withholds publishing from every guide shape when %s', async (_label, host) => {
|
||||
const guide = await skillsGet([], host)
|
||||
const full = await skillsGet(['--full'], host)
|
||||
const listed = await skillsGet(['--references'], host)
|
||||
const withheld = await skillsGet(['--reference', 'references/artifacts.md'], host)
|
||||
|
||||
expect(guide.stdout).not.toMatch(PUBLISHING_TEXT)
|
||||
expect(full.stdout).not.toMatch(PUBLISHING_TEXT)
|
||||
expect(full.stdout).not.toContain('host-capability')
|
||||
expect(listed.stdout.trim().split('\n')).toEqual(['automations', 'browser'])
|
||||
expect(withheld.stdout).toBe('')
|
||||
expect(withheld.stderr).toContain('turned off on this host')
|
||||
})
|
||||
|
||||
it('serves publishing guidance when the host has it on, asking the host once', async () => {
|
||||
const host: Host = {
|
||||
settings: { artifactSharingEnabled: true, agentSkillSharingEnabled: true }
|
||||
}
|
||||
const full = await skillsGet(['--full'], host)
|
||||
expect(hostState.calls).toBe(1)
|
||||
const listed = await skillsGet(['--references'], host)
|
||||
|
||||
expect(full.stdout).toContain('## Artifacts')
|
||||
expect(full.stdout).toContain('ORCA artifacts share <file> --json')
|
||||
expect(full.stdout).toContain('ORCA skills share --skill')
|
||||
expect(full.stdout).not.toContain('host-capability')
|
||||
expect(listed.stdout.trim().split('\n')).toEqual([
|
||||
'artifacts',
|
||||
'automations',
|
||||
'browser',
|
||||
'skill-sharing'
|
||||
])
|
||||
})
|
||||
|
||||
it.each<[string, Host, Record<string, string>]>([
|
||||
[
|
||||
'a remote runtime',
|
||||
{
|
||||
settings: { artifactSharingEnabled: true, agentSkillSharingEnabled: true },
|
||||
isRemote: true
|
||||
},
|
||||
{}
|
||||
],
|
||||
[
|
||||
'a forwarded invocation',
|
||||
{ settings: { artifactSharingEnabled: true, agentSkillSharingEnabled: true } },
|
||||
{ ORCA_CLI_CWD: '/remote/repo' }
|
||||
]
|
||||
])('withholds skill sharing from %s, where skills share refuses', async (_label, host, env) => {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
vi.stubEnv(name, value)
|
||||
}
|
||||
const guide = await skillsGet([], host)
|
||||
|
||||
expect(guide.stdout).toContain('## Artifacts')
|
||||
expect(guide.stdout).not.toContain('## Skill Sharing')
|
||||
})
|
||||
|
||||
it('serves an ungated reference without contacting the host', async () => {
|
||||
const { stdout, stderr } = await skillsGet(['--reference', 'browser'], 'no-access')
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { HandlerContext } from '../dispatch'
|
||||
|
||||
// Guides wrap feature-specific text in these blocks so agents only learn features the host has on.
|
||||
export const GUIDE_HOST_CAPABILITIES = ['artifact-publishing', 'skill-publishing'] as const
|
||||
export type GuideHostCapability = (typeof GUIDE_HOST_CAPABILITIES)[number]
|
||||
|
||||
const HOST_CAPABILITY_BLOCK =
|
||||
/<!-- host-capability: ([a-z-]+) -->\n+([\s\S]*?)\n+<!-- \/host-capability -->(\n*)/g
|
||||
|
||||
export function hasHostCapabilityBlocks(markdown: string): boolean {
|
||||
return markdown.includes('<!-- host-capability: ')
|
||||
}
|
||||
|
||||
export function filterHostCapabilityBlocks(markdown: string, enabled: ReadonlySet<string>): string {
|
||||
return markdown.replace(HOST_CAPABILITY_BLOCK, (_block, name: string, body: string, tail) =>
|
||||
enabled.has(name) ? `${body}${tail}` : ''
|
||||
)
|
||||
}
|
||||
|
||||
type SharingSettings = { artifactSharingEnabled?: boolean; agentSkillSharingEnabled?: boolean }
|
||||
|
||||
/** Mirrors the publish commands' targets; any unreadable state hides the blocks (fail closed). */
|
||||
export async function readEnabledHostCapabilities(
|
||||
ctx: HandlerContext
|
||||
): Promise<ReadonlySet<GuideHostCapability>> {
|
||||
const enabled = new Set<GuideHostCapability>()
|
||||
try {
|
||||
const response = await ctx.client.call<{ settings?: SharingSettings }>(
|
||||
'settings.get',
|
||||
undefined,
|
||||
{ timeoutMs: 2_000 }
|
||||
)
|
||||
const settings = response.result?.settings
|
||||
if (settings?.artifactSharingEnabled === true) {
|
||||
enabled.add('artifact-publishing')
|
||||
}
|
||||
// `skills share` refuses forwarded and remote invocations, so their hosts cannot publish skills.
|
||||
if (
|
||||
settings?.agentSkillSharingEnabled === true &&
|
||||
!process.env.ORCA_CLI_CWD &&
|
||||
!ctx.client.isRemote
|
||||
) {
|
||||
enabled.add('skill-publishing')
|
||||
}
|
||||
} catch {
|
||||
// Orca not running, sandboxed, or an older host; the publish commands still enforce the real gate.
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
@@ -6,7 +6,6 @@ vi.mock('./bundled-skill-guides.js', () => ({
|
||||
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' },
|
||||
@@ -17,7 +16,6 @@ vi.mock('./bundled-skill-guides.js', () => ({
|
||||
name: 'zeta',
|
||||
description: 'Use when zeta work is needed.',
|
||||
markdown: '# Zeta\n',
|
||||
fullMarkdown: '# Zeta\n',
|
||||
aliases: [],
|
||||
references: []
|
||||
}
|
||||
|
||||
+11
-8
@@ -21,15 +21,15 @@ vi.mock('./bundled-skill-guides.js', () => {
|
||||
name: 'zeta',
|
||||
description: 'Use when zeta work\nspans lines.',
|
||||
markdown: '# Zeta\n',
|
||||
fullMarkdown: '# Zeta\n\n## References\n\nZeta reference.\n',
|
||||
aliases: []
|
||||
aliases: [],
|
||||
references: []
|
||||
},
|
||||
{
|
||||
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']
|
||||
aliases: ['legacy-alpha'],
|
||||
references: [{ name: 'deep', markdown: '# Deep\n\nFull.\n' }]
|
||||
},
|
||||
{
|
||||
name: 'gamma',
|
||||
@@ -37,8 +37,8 @@ vi.mock('./bundled-skill-guides.js', () => {
|
||||
'Use when gamma work spans several sentences describing exactly how a ' +
|
||||
'coding agent should decide whether gamma applies to the current task at hand.',
|
||||
markdown: '# Gamma\n',
|
||||
fullMarkdown: '# Gamma\n\n## References\n\nGamma reference.\n',
|
||||
aliases: []
|
||||
aliases: [],
|
||||
references: []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -121,7 +121,9 @@ describe('orca skills CLI', () => {
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--full'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('# Alpha\n\nShort.\n\n## References\n\nFull.\n')
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
'# Alpha\n\nShort.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n<!-- bundled-reference: references/deep.md -->\n\n# Deep\n\nFull.\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('supports the canonical single-item show verb as an alias', async () => {
|
||||
@@ -167,7 +169,8 @@ describe('orca skills CLI', () => {
|
||||
{
|
||||
name: 'alpha',
|
||||
full: true,
|
||||
markdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n'
|
||||
markdown:
|
||||
'# Alpha\n\nShort.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n<!-- bundled-reference: references/deep.md -->\n\n# Deep\n\nFull.\n'
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -27,7 +27,8 @@ function findSpecForInvocation(invocation: string): CommandSpec | undefined {
|
||||
function collectGuideInvocations(): GuideInvocation[] {
|
||||
const found: GuideInvocation[] = []
|
||||
for (const guide of BUNDLED_SKILL_GUIDES) {
|
||||
for (const match of guide.fullMarkdown.matchAll(CLI_INVOCATION)) {
|
||||
const documents = [guide.markdown, ...guide.references.map((reference) => reference.markdown)]
|
||||
for (const match of documents.join('\n').matchAll(CLI_INVOCATION)) {
|
||||
const invocation = match[1].trim()
|
||||
const spec = findSpecForInvocation(invocation)
|
||||
if (!spec) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user