fix(skills): pin the skills CLI and contract-test --agent scoping (#11593)

This commit is contained in:
Brennan Benson
2026-07-30 16:42:23 -07:00
parent 35c76ba07a
commit 611ecb02b2
22 changed files with 307 additions and 63 deletions
@@ -0,0 +1,37 @@
name: Skill add agent scoping
on:
pull_request:
paths:
- 'src/shared/skills-cli-version.ts'
- 'src/shared/agent-feature-install-commands.ts'
- 'src/cli/handlers/skills.ts'
- 'src/main/skills/skill-update-run.ts'
- 'config/scripts/verify-skill-add-agent-scoping.mjs'
- '.github/workflows/skill-add-agent-scoping.yml'
merge_group:
push:
branches:
- main
jobs:
scoping:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
skills-cli: [pinned]
include:
- os: ubuntu-latest
skills-cli: latest
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Verify --agent scoping against the resolved skills CLI
run: node config/scripts/verify-skill-add-agent-scoping.mjs --cli=${{ matrix.skills-cli }}
@@ -0,0 +1,151 @@
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
// Proves the resolved skills CLI still honours our --agent scoping (#11593).
// The vendor's parser silently drops flags it does not recognize, and its
// zero-detected non-interactive branch installs into every agent it knows
// (~75), so a renamed --agent would revive that fan-out with exit 0. Run 1
// asserts our exact argv shape stays scoped; run 2 replays the rename (our
// flag unknown, its value demoted to an ignored positional) and asserts the
// fan-out still exists — if run 2 ever stops fanning out, the failure shape
// this contract discriminates on has changed and needs a human re-read.
function option(name) {
return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
}
const cliOption = option('cli') ?? 'pinned'
const pinSource = await readFile('src/shared/skills-cli-version.ts', 'utf8')
const pinnedVersion = /SKILLS_CLI_VERSION = '([^']+)'/.exec(pinSource)?.[1]
if (!pinnedVersion) {
throw new Error('Could not read SKILLS_CLI_VERSION from src/shared/skills-cli-version.ts')
}
// Why: defaulting to the pin means a version-bump PR re-proves the contract
// against the new version with no workflow edit; `latest` is the drift canary.
const cliVersion = cliOption === 'pinned' ? pinnedVersion : cliOption
const FANOUT_MIN = 20
const isWindows = process.platform === 'win32'
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-add-agent-scoping-'))
const fixture = path.join(sandbox, 'fixture')
const probeName = 'scoping-probe'
// Why: an explicit allowlist, never process.env — inherited agent homes
// (CODEX_HOME), in-agent markers, or agent CLIs on PATH would make the CLI's
// detection non-zero and mask the zero-detected branch this contract pins.
const nodeBinDir = path.dirname(process.execPath)
const systemRoot = process.env.SystemRoot ?? 'C:\\Windows'
const scrubbedPath = isWindows
? [nodeBinDir, path.join(systemRoot, 'System32'), systemRoot].join(path.delimiter)
: [nodeBinDir, '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter)
function sandboxEnv(home) {
return {
PATH: scrubbedPath,
HOME: home,
USERPROFILE: home,
XDG_STATE_HOME: path.join(sandbox, 'state'),
npm_config_cache: path.join(sandbox, 'npm-cache'),
TMPDIR: path.join(sandbox, 'tmp'),
TEMP: path.join(sandbox, 'tmp'),
TMP: path.join(sandbox, 'tmp'),
DO_NOT_TRACK: '1',
CI: '1',
...(isWindows
? {
APPDATA: path.join(home, 'AppData', 'Roaming'),
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
SystemRoot: systemRoot,
windir: process.env.windir ?? systemRoot,
ComSpec: process.env.ComSpec ?? path.join(systemRoot, 'System32', 'cmd.exe'),
PATHEXT: process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD'
}
: {})
}
}
function execSkills(home, args) {
const executable = isWindows ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
const cliArgs = ['--yes', `skills@${cliVersion}`, ...args]
execFileSync(executable, isWindows ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs, {
// Why: the sandbox cwd keeps the repo's own .npmrc out of the child npm.
cwd: sandbox,
env: sandboxEnv(home),
stdio: 'inherit'
})
}
// npm/npx working directories the child may create in a bare home; everything
// else a run leaves behind is an agent placement.
const NON_AGENT_ENTRIES = new Set(['.npm', '.cache', '.config', '.local', 'AppData'])
async function agentEntries(home) {
const entries = await readdir(home).catch(() => [])
return entries.filter((entry) => !NON_AGENT_ENTRIES.has(entry)).sort()
}
try {
await mkdir(path.join(fixture, 'skills', probeName), { recursive: true })
await writeFile(
path.join(fixture, 'skills', probeName, 'SKILL.md'),
`---\nname: ${probeName}\ndescription: Contract probe for --agent scoping.\n---\n\n# Scoping probe\n`
)
await mkdir(path.join(sandbox, 'tmp'), { recursive: true })
// Run 1 — the exact argv shape Orca spawns, minus the repo source: --agent
// must both skip detection and confine placements to its target.
const homeScoped = path.join(sandbox, 'home-scoped')
await mkdir(homeScoped, { recursive: true })
execSkills(homeScoped, [
'add',
fixture,
'--skill',
probeName,
'--global',
'--agent',
'claude-code',
'-y'
])
const scoped = await agentEntries(homeScoped)
await stat(path.join(homeScoped, '.claude', 'skills', probeName))
// The CLI may also write the canonical universal placement; anything else is
// the fan-out this contract exists to catch.
const leaked = scoped.filter((entry) => entry !== '.claude' && entry !== '.agents')
if (!scoped.includes('.claude') || leaked.length > 0) {
throw new Error(
`skills@${cliVersion} no longer honours --agent claude-code: placements [${scoped.join(', ')}]`
)
}
console.log(`[skill-add-agent-scoping] scoped install placed only: ${scoped.join(', ')}`)
// Run 2 — the upstream-rename replay: an unknown flag whose value degrades
// to an ignored positional. Today that means the all-agents fan-out.
const homeDrift = path.join(sandbox, 'home-drift')
await mkdir(homeDrift, { recursive: true })
execSkills(homeDrift, [
'add',
fixture,
'--skill',
probeName,
'--global',
'--orca-scoping-contract-probe',
'claude-code',
'-y'
])
const drifted = await agentEntries(homeDrift)
if (drifted.length < FANOUT_MIN) {
throw new Error(
`The rename replay produced ${drifted.length} agent placements (expected >= ${FANOUT_MIN}). ` +
`skills@${cliVersion} changed the unscoped non-interactive branch; re-read its target ` +
'resolution before trusting this contract.'
)
}
console.log(
`[skill-add-agent-scoping] rename replay fanned out to ${drifted.length} agent placements`
)
} finally {
await rm(sandbox, { recursive: true, force: true })
}
+15 -15
View File
@@ -336,7 +336,7 @@ describe('orca skills CLI', () => {
await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
'npx --yes skills@1.5.21 add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
'Rerun without --dry-run to install now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
@@ -351,7 +351,7 @@ describe('orca skills CLI', () => {
`${JSON.stringify(
{
command:
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y',
'npx --yes skills@1.5.21 add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y',
skills: ['alpha'],
global: true,
executed: false
@@ -368,7 +368,7 @@ describe('orca skills CLI', () => {
await main(['skills', 'install', '--skill', 'alpha', '--local', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y\n\n' +
'npx --yes skills@1.5.21 add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y\n\n' +
'Rerun without --dry-run to install now.\n'
)
@@ -382,7 +382,7 @@ describe('orca skills CLI', () => {
`${JSON.stringify(
{
command:
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y',
'npx --yes skills@1.5.21 add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y',
skills: ['alpha'],
global: false,
executed: false
@@ -407,7 +407,7 @@ describe('orca skills CLI', () => {
'npx',
[
'--yes',
'skills',
'skills@1.5.21',
'add',
'https://github.com/stablyai/orca',
'--skill',
@@ -442,7 +442,7 @@ describe('orca skills CLI', () => {
'/c',
'C:\\Program Files\\nodejs\\npx.cmd',
'--yes',
'skills',
'skills@1.5.21',
'add',
'https://github.com/stablyai/orca',
'--skill',
@@ -490,7 +490,7 @@ describe('orca skills CLI', () => {
'npx',
[
'--yes',
'skills',
'skills@1.5.21',
'add',
'https://github.com/stablyai/orca',
'--skill',
@@ -520,7 +520,7 @@ describe('orca skills CLI', () => {
'npx',
[
'--yes',
'skills',
'skills@1.5.21',
'add',
'https://github.com/stablyai/orca',
'--skill',
@@ -584,7 +584,7 @@ describe('orca skills CLI', () => {
await main(['skills', 'update', '--skill', 'legacy-alpha', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills update alpha --global -y\n\nRerun without --dry-run to update now.\n'
'npx --yes skills@1.5.21 update alpha --global -y\n\nRerun without --dry-run to update now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
})
@@ -600,7 +600,7 @@ describe('orca skills CLI', () => {
expect(stdoutText(stdoutSpy)).toBe(
`${JSON.stringify(
{
command: 'npx --yes skills update alpha --project -y',
command: 'npx --yes skills@1.5.21 update alpha --project -y',
skills: ['alpha'],
global: false,
executed: false
@@ -623,7 +623,7 @@ describe('orca skills CLI', () => {
expect(spawnMock).toHaveBeenCalledWith(
'npx',
['--yes', 'skills', 'update', 'alpha', '--project', '-y'],
['--yes', 'skills@1.5.21', 'update', 'alpha', '--project', '-y'],
expect.objectContaining({ stdio: 'inherit' })
)
})
@@ -847,7 +847,7 @@ describe('orca skills CLI', () => {
'npx',
[
'--yes',
'skills',
'skills@1.5.21',
'add',
'https://github.com/stablyai/orca',
'--skill',
@@ -874,7 +874,7 @@ describe('orca skills CLI', () => {
)
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
'npx --yes skills@1.5.21 add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
'Rerun without --dry-run to install now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
@@ -892,7 +892,7 @@ describe('orca skills CLI', () => {
// Why: stdout belongs to the child, so this record has to go to stderr.
expect(stderrSpy).toHaveBeenCalledWith(
'Running: npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n'
'Running: npx --yes skills@1.5.21 add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n'
)
})
@@ -908,7 +908,7 @@ describe('orca skills CLI', () => {
expect(spawnMock).toHaveBeenCalledWith(
'npx',
['--yes', 'skills', 'update', 'alpha', 'gamma', 'zeta', '--global', '-y'],
['--yes', 'skills@1.5.21', 'update', 'alpha', 'gamma', 'zeta', '--global', '-y'],
expect.objectContaining({ stdio: 'inherit' })
)
expect(process.exitCode).toBe(2)
@@ -171,7 +171,7 @@ describe('skill freshness name-scoped update eligibility', () => {
it('builds only an explicit, deterministic global command', () => {
expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe(
'npx skills update orca-cli orchestration --global'
'npx skills@1.5.21 update orca-cli orchestration --global'
)
expect(buildTargetedSkillUpdateCommand([])).toBeNull()
expect(buildTargetedSkillUpdateCommand(['orca-cli;echo unsafe'])).toBeNull()
+1 -1
View File
@@ -51,7 +51,7 @@ describe('SkillUpdateRunner', () => {
// non-interactive branch. Dropping either can wedge the run.
expect(spawnCalls[0].args).toEqual([
'--yes',
'skills',
'skills@1.5.21',
'update',
'orca-cli',
'orchestration',
+10 -2
View File
@@ -4,6 +4,7 @@ import {
type SkillUpdateRun,
type SkillUpdateStartResult
} from '../../shared/skill-freshness'
import { SKILLS_CLI_PACKAGE_SPEC } from '../../shared/skills-cli-version'
import { resolveCliCommand } from '../codex-cli/command'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import { getSpawnArgsForWindows } from '../win32-utils'
@@ -47,7 +48,7 @@ function clampOutput(value: string): string {
}
/**
* Runs `npx --yes skills update <names> --global -y` headlessly.
* Runs `npx --yes skills@<pin> update <names> --global -y` headlessly.
*
* Both `--yes` flags are load-bearing and distinct: `npx --yes` skips the
* install-this-package prompt, and `skills … -y` takes the CLI's own
@@ -92,7 +93,14 @@ export class SkillUpdateRunner {
const resolveCommand = this.deps.resolveCommand ?? ((name: string) => resolveCliCommand(name))
const spawnProcess = this.deps.spawnProcess ?? spawn
const npxCommand = resolveCommand('npx')
const npxArgs = ['--yes', 'skills', 'update', ...canonicalNames, '--global', '-y']
const npxArgs = [
'--yes',
SKILLS_CLI_PACKAGE_SPEC,
'update',
...canonicalNames,
'--global',
'-y'
]
let spawnCmd: string
let spawnArgs: string[]
@@ -119,7 +119,7 @@ describe('onboarding feature setup runner', () => {
expect(text).toBe(ALL_SKILL_INSTALL_COMMAND)
expect(text).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill computer-use --skill orchestration --skill orca-linear --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-cli --skill computer-use --skill orchestration --skill orca-linear --global'
)
})
@@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import { TooltipProvider } from '../ui/tooltip'
const INSTALL_COMMAND = 'npx skills add https://github.com/stablyai/orca --skill orca-cli --global'
const UPDATE_COMMAND = 'npx skills update orca-cli --global'
const INSTALL_COMMAND =
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-cli --global'
const UPDATE_COMMAND = 'npx skills@1.5.21 update orca-cli --global'
const mocks = vi.hoisted(() => ({
clipboardWrite: vi.fn(),
@@ -16,8 +16,8 @@ vi.mock('./AgentSkillSetupPanel', () => ({
describe('BrowserUseSkillStep', () => {
it('forwards a single-skill installed command even when setup installs a bundle', () => {
const bundleInstallCommand =
'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global'
const updateCommand = 'npx skills update orca-cli --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global'
const updateCommand = 'npx skills@1.5.21 update orca-cli --global'
renderToStaticMarkup(
<BrowserUseSkillStep
@@ -181,6 +181,21 @@ describe('CliSkillRuntimeSetup runtime helpers', () => {
).toBe(`${windowsNpxPreflightPrefix}${windowsNpxGuidance}) else (${installCommand})"`)
})
it('rewrites the version-pinned update command on the Windows host path too', () => {
const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration'])
expect(
buildSkillCommandForRuntime(
'npx skills@1.5.21 update orchestration --global',
{
runtime: 'host',
label: 'Windows'
},
'win32'
)
).toBe(`${windowsNpxPreflightPrefix}${windowsNpxGuidance}) else (${installCommand})"`)
})
it('treats missing runtime as a preflighted Windows host fallback for skill updates', () => {
const installCommand = buildAgentFeatureSkillInstallCommand(['orca-cli'])
@@ -115,7 +115,9 @@ function normalizeWindowsSkillUpdateCommand(
}
const trimmedCommand = command.trim()
const updateMatch = /^npx\s+skills\s+update\s+([A-Za-z0-9_-]+)\s+--global$/i.exec(trimmedCommand)
const updateMatch = /^npx\s+skills(?:@\S+)?\s+update\s+([A-Za-z0-9_-]+)\s+--global$/i.exec(
trimmedCommand
)
if (!updateMatch) {
return command
}
@@ -136,7 +138,7 @@ function wrapWindowsSkillCommandWithNpxPrerequisite(
// Why: skill setup terminals spawn on the focused runtime environment, so a
// Windows client must not hand a cmd.exe command to a remote host.
isRemoteRuntimeEnvironmentFocused() ||
!/^npx\s+skills\s+(?:add|update)\b/i.test(trimmedCommand)
!/^npx\s+skills(?:@\S+)?\s+(?:add|update)\b/i.test(trimmedCommand)
) {
return command
}
@@ -11,7 +11,7 @@ import {
import { getLinearUsageExamples } from '@/lib/linear-usage-examples'
import { LinearAgentSkillPane } from './LinearAgentSkillPane'
const UPDATE_COMMAND = 'npx skills update orca-linear --global'
const UPDATE_COMMAND = 'npx skills@1.5.21 update orca-linear --global'
const mocks = vi.hoisted(() => ({
panelProps: [] as Record<string, unknown>[],
@@ -8,10 +8,10 @@ import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-example
import { OrchestrationPane } from './OrchestrationPane'
const INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orchestration --global'
const UPDATE_COMMAND = INSTALL_COMMAND
const WINDOWS_INSTALL_COMMAND =
'cmd.exe /d /s /c "where.exe npx >nul 2>nul & if errorlevel 1 (echo ERROR: npx was not found. Install Node.js LTS from https://nodejs.org/ to get npx. & echo Then close this terminal and start skill setup again - a new terminal picks up the updated PATH. & exit /b 1) else (npx skills add https://github.com/stablyai/orca --skill orchestration --global)"'
'cmd.exe /d /s /c "where.exe npx >nul 2>nul & if errorlevel 1 (echo ERROR: npx was not found. Install Node.js LTS from https://nodejs.org/ to get npx. & echo Then close this terminal and start skill setup again - a new terminal picks up the updated PATH. & exit /b 1) else (npx skills@1.5.21 add https://github.com/stablyai/orca --skill orchestration --global)"'
const mocks = vi.hoisted(() => ({
dialogProps: [] as Record<string, unknown>[],
@@ -117,7 +117,7 @@ describe('LinearAgentSkillInstallCta', () => {
'Full guided setup (connect + skill + visibility) is under Settings → Task Sources.'
)
expect(rendered.textContent).toContain(
'npx skills add https://github.com/stablyai/orca --skill orca-linear --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-linear --global'
)
})
@@ -131,7 +131,7 @@ describe('LinearAgentSkillInstallCta', () => {
})
expect(mocks.clipboardWrite).toHaveBeenCalledWith(
'npx skills add https://github.com/stablyai/orca --skill orca-linear --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-linear --global'
)
expect(mocks.toastSuccess).toHaveBeenCalled()
})
@@ -144,7 +144,7 @@ describe('LinearAgentSkillInstallCta', () => {
expect(rendered.textContent).toContain('Installed')
expect(rendered.textContent).toContain('Agent skill installed. To update it, run:')
expect(rendered.textContent).toContain('npx skills update orca-linear --global')
expect(rendered.textContent).toContain('npx skills@1.5.21 update orca-linear --global')
expect(rendered.textContent).not.toContain('Not installed')
})
@@ -160,7 +160,7 @@ describe('LinearAgentSkillInstallCta', () => {
const rendered = await renderCta()
expect(rendered.textContent).toContain('npx skills update linear-tickets --global')
expect(rendered.textContent).toContain('npx skills@1.5.21 update linear-tickets --global')
})
it('notes that remote agent environments need their own setup', async () => {
@@ -344,11 +344,11 @@ describe('LinearAgentSkillSetupPrompt', () => {
setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(document.body.textContent).toContain("wsl.exe -d 'Fedora' -- bash -lc 'npx skills add")
expect(document.body.textContent).toContain("-d 'Fedora' -- bash -lc 'npx skills@1.5.21 add")
expect(mocks.panelProps.at(-1)).toEqual(
expect.objectContaining({
installedCommand:
"wsl.exe -d 'Fedora' -- bash -lc 'npx skills update orca-linear --global'",
"wsl.exe -d 'Fedora' -- bash -lc 'npx skills@1.5.21 update orca-linear --global'",
terminalShellOverride: 'powershell.exe',
getPrerequisiteStatus: expect.any(Function)
})
@@ -156,7 +156,7 @@ describe('LinearAgentSkillSetupPrompt update command', () => {
await renderPrompt()
expect(mocks.panelProps.at(-1)).toEqual(
expect.objectContaining({ installedCommand: 'npx skills update orca-linear --global' })
expect.objectContaining({ installedCommand: 'npx skills@1.5.21 update orca-linear --global' })
)
})
@@ -166,7 +166,9 @@ describe('LinearAgentSkillSetupPrompt update command', () => {
await renderPrompt()
expect(mocks.panelProps.at(-1)).toEqual(
expect.objectContaining({ installedCommand: 'npx skills update linear-tickets --global' })
expect.objectContaining({
installedCommand: 'npx skills@1.5.21 update linear-tickets --global'
})
)
})
@@ -176,7 +178,7 @@ describe('LinearAgentSkillSetupPrompt update command', () => {
await renderPrompt()
expect(mocks.panelProps.at(-1)).toEqual(
expect.objectContaining({ installedCommand: 'npx skills update orca-linear --global' })
expect.objectContaining({ installedCommand: 'npx skills@1.5.21 update orca-linear --global' })
)
})
})
@@ -499,7 +499,7 @@ describe('SkillFreshnessUpdateDialog', () => {
await openViaRequest()
expect(container?.textContent).toContain(
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orchestration --global'
)
})
@@ -530,7 +530,7 @@ describe('SkillFreshnessUpdateDialog', () => {
const row = container?.querySelector('[data-skill-row="orchestration"]')
expect(row?.textContent).toContain(
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orchestration --global'
)
expect(row?.textContent).not.toContain('This is a project skill, not a global one')
// Still listed, though — ownership silences the explanation, never the location.
@@ -40,7 +40,7 @@ describe('skippedReason', () => {
const reason = skippedReason([row(null)], 'orchestration')
expect(reason).toContain('reports the skill as already up to date')
expect(reason).toContain(
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orchestration --global'
)
})
@@ -48,7 +48,7 @@ describe('skippedReason', () => {
// Why: reinstalling a newer copy rolls the user back to what this build ships.
const reason = skippedReason([row('newer')], 'orchestration')
expect(reason).toContain('later version')
expect(reason).not.toContain('skills add')
expect(reason).not.toContain('skills@1.5.21 add')
})
it('keeps a placement blocker ahead of the record advice', () => {
@@ -63,7 +63,7 @@ describe('skippedReason', () => {
// skip swapped the one runnable command for advice about a copy Orca never judged.
const reason = skippedReason([row(null), projectRow()], 'orchestration')
expect(reason).toContain('reports the skill as already up to date')
expect(reason).toContain('skills add')
expect(reason).toContain('skills@1.5.21 add')
expect(reason).not.toContain('This is a project skill')
})
@@ -17,22 +17,22 @@ import {
describe('agent feature skill commands', () => {
it('builds a global install command by default', () => {
expect(buildAgentFeatureSkillInstallCommand(['orca-cli'])).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-cli --global'
)
})
it('drops --global when installing locally', () => {
expect(buildAgentFeatureSkillInstallCommand(['orca-cli'], { global: false })).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-cli'
)
})
it('repeats --skill per name for multi-skill installs', () => {
expect(buildAgentFeatureSkillInstallCommand(['orca-cli', 'orchestration'])).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global'
)
expect(buildAgentFeatureSkillInstallArgs(['orca-cli', 'orchestration'])).toEqual([
'skills',
'skills@1.5.21',
'add',
'https://github.com/stablyai/orca',
'--skill',
@@ -76,10 +76,10 @@ describe('agent feature skill commands', () => {
expect(
buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true, agents: ['universal'] })
).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli --global --agent universal -y'
'npx skills@1.5.21 add https://github.com/stablyai/orca --skill orca-cli --global --agent universal -y'
)
expect(buildAgentFeatureSkillUpdateCommand(['orca-cli'], { global: false, yes: true })).toBe(
'npx skills update orca-cli --project -y'
'npx skills@1.5.21 update orca-cli --project -y'
)
expect(
buildAgentFeatureSkillInstallArgs(['orca-cli'], { yes: true, agents: ['universal'] }).at(-1)
@@ -89,26 +89,26 @@ describe('agent feature skill commands', () => {
it('builds single-skill update commands', () => {
expect(buildAgentFeatureSkillUpdateCommand('orchestration')).toBe(
'npx skills update orchestration --global'
'npx skills@1.5.21 update orchestration --global'
)
})
it('trims and rejects blank update skill names', () => {
expect(buildAgentFeatureSkillUpdateCommand(' orca-cli ')).toBe(
'npx skills update orca-cli --global'
'npx skills@1.5.21 update orca-cli --global'
)
expect(() => buildAgentFeatureSkillUpdateCommand(' ')).toThrow('A skill name is required.')
})
it('builds multi-skill update commands and selects project scope for --local', () => {
expect(buildAgentFeatureSkillUpdateCommand(['orca-cli', 'orchestration'])).toBe(
'npx skills update orca-cli orchestration --global'
'npx skills@1.5.21 update orca-cli orchestration --global'
)
expect(buildAgentFeatureSkillUpdateCommand(['orca-cli'], { global: false })).toBe(
'npx skills update orca-cli --project'
'npx skills@1.5.21 update orca-cli --project'
)
expect(buildAgentFeatureSkillUpdateArgs(['orca-cli'], { global: false })).toEqual([
'skills',
'skills@1.5.21',
'update',
'orca-cli',
'--project'
@@ -117,16 +117,30 @@ describe('agent feature skill commands', () => {
})
it('exports single-skill update constants without changing install bundles', () => {
expect(ORCA_CLI_SKILL_UPDATE_COMMAND).toBe('npx skills update orca-cli --global')
expect(COMPUTER_USE_SKILL_UPDATE_COMMAND).toBe('npx skills update computer-use --global')
expect(ORCHESTRATION_SKILL_UPDATE_COMMAND).toBe('npx skills update orchestration --global')
expect(ORCA_CLI_SKILL_UPDATE_COMMAND).toBe('npx skills@1.5.21 update orca-cli --global')
expect(COMPUTER_USE_SKILL_UPDATE_COMMAND).toBe('npx skills@1.5.21 update computer-use --global')
expect(ORCHESTRATION_SKILL_UPDATE_COMMAND).toBe(
'npx skills@1.5.21 update orchestration --global'
)
expect(EPHEMERAL_VMS_SKILL_UPDATE_COMMAND).toBe(
'npx skills update orca-per-workspace-env --global'
'npx skills@1.5.21 update orca-per-workspace-env --global'
)
expect(ORCA_LINEAR_SKILL_UPDATE_COMMAND).toBe('npx skills@1.5.21 update orca-linear --global')
expect(LINEAR_TICKETS_SKILL_UPDATE_COMMAND).toBe(
'npx skills@1.5.21 update linear-tickets --global'
)
expect(ORCA_LINEAR_SKILL_UPDATE_COMMAND).toBe('npx skills update orca-linear --global')
expect(LINEAR_TICKETS_SKILL_UPDATE_COMMAND).toBe('npx skills update linear-tickets --global')
expect(ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND).toBe(
buildAgentFeatureSkillInstallCommand(['orca-cli', 'orchestration'])
)
})
it('pins the skills CLI to one exact version in every invocation', () => {
// Why: an unpinned `skills` floats to npm latest, whose parser silently
// drops unknown flags — an upstream --agent rename would revive the
// ~75-agent fan-out install with exit 0 (#11593). Exact only, never a
// range: cmd.exe treats ^ as its escape character in pasteable commands.
const exactPin = /^skills@\d+\.\d+\.\d+$/
expect(buildAgentFeatureSkillInstallArgs(['orca-cli'])[0]).toMatch(exactPin)
expect(buildAgentFeatureSkillUpdateArgs('orca-cli')[0]).toMatch(exactPin)
})
})
+3 -2
View File
@@ -1,4 +1,5 @@
import { isSkillsCliAgentKeyShaped } from './skills-cli-agent-keys'
import { SKILLS_CLI_PACKAGE_SPEC } from './skills-cli-version'
export const ORCA_SKILLS_REPOSITORY_URL = 'https://github.com/stablyai/orca'
@@ -42,7 +43,7 @@ export function buildAgentFeatureSkillInstallArgs(
// Why: one flag per name remains compatible with both single-value and variadic parsers.
const skillArgs = skillNames.flatMap((name) => ['--skill', name])
return [
'skills',
SKILLS_CLI_PACKAGE_SPEC,
'add',
ORCA_SKILLS_REPOSITORY_URL,
...skillArgs,
@@ -75,7 +76,7 @@ export function buildAgentFeatureSkillUpdateArgs(
}
const global = options.global ?? true
return [
'skills',
SKILLS_CLI_PACKAGE_SPEC,
'update',
...names,
global ? '--global' : '--project',
+4 -1
View File
@@ -1,4 +1,5 @@
import type { SkillProvider, SkillSourceKind } from './skills'
import { SKILLS_CLI_PACKAGE_SPEC } from './skills-cli-version'
export type SkillBundleFileIdentity = {
path: string
@@ -209,7 +210,9 @@ export function canonicalizeSkillUpdateNames(names: readonly string[]): string[]
export function buildTargetedSkillUpdateCommand(names: readonly string[]): string | null {
const canonicalNames = canonicalizeSkillUpdateNames(names)
return canonicalNames ? `npx skills update ${canonicalNames.join(' ')} --global` : null
return canonicalNames
? `npx ${SKILLS_CLI_PACKAGE_SPEC} update ${canonicalNames.join(' ')} --global`
: null
}
// Why: `skills update` has no --json (that flag only exists on `list`), so the
+10
View File
@@ -0,0 +1,10 @@
// Why: `skills` floats to npm latest at spawn time, and its parser silently
// drops flags it does not recognize — an upstream --agent rename would revive
// the ~75-agent fan-out install with exit 0 (#11593). Pinning freezes the argv
// contract (published npm versions are immutable), and CI re-proves --agent
// scoping against this exact version, so a bump that breaks scoping goes red.
export const SKILLS_CLI_VERSION = '1.5.21'
// Why: an exact spec, never a range — `^` is cmd.exe's escape character, so a
// caret range inside a pasteable Settings command would be silently mangled.
export const SKILLS_CLI_PACKAGE_SPEC = `skills@${SKILLS_CLI_VERSION}`