From 611ecb02b2b4ceffa30cb147b65a5335096c81bb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:00:52 -0700 Subject: [PATCH] fix(skills): pin the skills CLI and contract-test --agent scoping (#11593) --- .github/workflows/skill-add-agent-scoping.yml | 37 +++++ .../verify-skill-add-agent-scoping.mjs | 151 ++++++++++++++++++ src/cli/skills.test.ts | 30 ++-- .../skill-freshness-eligibility.test.ts | 2 +- src/main/skills/skill-update-run.test.ts | 2 +- src/main/skills/skill-update-run.ts | 12 +- .../onboarding-feature-setup.test.ts | 2 +- .../settings/AgentSkillSetupPanel.test.tsx | 5 +- .../settings/BrowserUseSkillStep.test.tsx | 4 +- .../settings/CliSkillRuntimeSetup.test.tsx | 15 ++ .../settings/CliSkillRuntimeSetup.tsx | 6 +- .../settings/LinearAgentSkillPane.test.tsx | 2 +- .../settings/OrchestrationPane.test.tsx | 4 +- .../linear-agent-skill-install-cta.test.tsx | 8 +- .../LinearAgentSkillSetupPrompt.test.tsx | 4 +- ...ntSkillSetupPrompt.update-command.test.tsx | 8 +- .../SkillFreshnessUpdateDialog.test.tsx | 4 +- .../skill-freshness-skipped-reason.test.ts | 6 +- .../agent-feature-install-commands.test.ts | 48 ++++-- src/shared/agent-feature-install-commands.ts | 5 +- src/shared/skill-freshness.ts | 5 +- src/shared/skills-cli-version.ts | 10 ++ 22 files changed, 307 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/skill-add-agent-scoping.yml create mode 100644 config/scripts/verify-skill-add-agent-scoping.mjs create mode 100644 src/shared/skills-cli-version.ts diff --git a/.github/workflows/skill-add-agent-scoping.yml b/.github/workflows/skill-add-agent-scoping.yml new file mode 100644 index 00000000000..ee8bca9b102 --- /dev/null +++ b/.github/workflows/skill-add-agent-scoping.yml @@ -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 }} diff --git a/config/scripts/verify-skill-add-agent-scoping.mjs b/config/scripts/verify-skill-add-agent-scoping.mjs new file mode 100644 index 00000000000..37bd56af553 --- /dev/null +++ b/config/scripts/verify-skill-add-agent-scoping.mjs @@ -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 }) +} diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 1cdee5aad4b..e53620b0a0e 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -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) diff --git a/src/main/skills/skill-freshness-eligibility.test.ts b/src/main/skills/skill-freshness-eligibility.test.ts index ad1517c78a9..c94a3bd9238 100644 --- a/src/main/skills/skill-freshness-eligibility.test.ts +++ b/src/main/skills/skill-freshness-eligibility.test.ts @@ -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() diff --git a/src/main/skills/skill-update-run.test.ts b/src/main/skills/skill-update-run.test.ts index 0aeb31bf32e..5a9bd4c1583 100644 --- a/src/main/skills/skill-update-run.test.ts +++ b/src/main/skills/skill-update-run.test.ts @@ -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', diff --git a/src/main/skills/skill-update-run.ts b/src/main/skills/skill-update-run.ts index 9083b24e43f..7c4cd902133 100644 --- a/src/main/skills/skill-update-run.ts +++ b/src/main/skills/skill-update-run.ts @@ -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 --global -y` headlessly. + * Runs `npx --yes skills@ update --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[] diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts index 9e40f19d903..7be3dcfe8d0 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts @@ -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' ) }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 533144f8bdf..5a047843803 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -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(), diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx index 90e9aa9f0c4..cacc767dab5 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx @@ -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( { ).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']) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index bee39049fc5..b4c3fe83eb7 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -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 } diff --git a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx index e8e0dd442a0..4fdba603b0f 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx @@ -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[], diff --git a/src/renderer/src/components/settings/OrchestrationPane.test.tsx b/src/renderer/src/components/settings/OrchestrationPane.test.tsx index 914ba8a6664..bc0869230df 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.test.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.test.tsx @@ -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[], diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx index 793e9985d88..3935348373f 100644 --- a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx @@ -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 () => { diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index 0d784cb1033..8ffb591c327 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -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) }) diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx index 7ff473b49da..444ae2c7cc9 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx @@ -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' }) ) }) }) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index 1a53ecdcf10..c7b203221a6 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -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. diff --git a/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts b/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts index 5f04d122dd6..a72c30360f6 100644 --- a/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts +++ b/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts @@ -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') }) diff --git a/src/shared/agent-feature-install-commands.test.ts b/src/shared/agent-feature-install-commands.test.ts index 33843953560..88ddad0ac14 100644 --- a/src/shared/agent-feature-install-commands.test.ts +++ b/src/shared/agent-feature-install-commands.test.ts @@ -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) + }) }) diff --git a/src/shared/agent-feature-install-commands.ts b/src/shared/agent-feature-install-commands.ts index 14f3e6d84dc..4e42bc86568 100644 --- a/src/shared/agent-feature-install-commands.ts +++ b/src/shared/agent-feature-install-commands.ts @@ -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', diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index 24bbcc9df38..24a07d70a1e 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -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 diff --git a/src/shared/skills-cli-version.ts b/src/shared/skills-cli-version.ts new file mode 100644 index 00000000000..33b8fac5076 --- /dev/null +++ b/src/shared/skills-cli-version.ts @@ -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}`