diff --git a/.github/workflows/ci-closed-pr-caches.yml b/.github/workflows/ci-closed-pr-caches.yml index b85e1fe4e7d..e3bb05def80 100644 --- a/.github/workflows/ci-closed-pr-caches.yml +++ b/.github/workflows/ci-closed-pr-caches.yml @@ -47,7 +47,6 @@ jobs: ['ci-cache-warmup-platforms', 'ci-cache-warmup-platforms.yml'], ['pi-owner-runtime', 'pi-owner-runtime.yml'], ['git-command-termination', 'git-command-termination-runtime.yml'], - ['skill-roundtrip-pull_request', 'skill-update-roundtrip.yml'], ['ci-cache-warmup', 'ci-cache-warmup.yml'] ] const stillCurrent = async () => { diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 3a4d09c52df..03d1b8479f3 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1017,7 +1017,6 @@ jobs: WORKFLOW_SHA: ${{ github.workflow_sha }} run: | git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" - # Why not skill-freshness-inventory.test.ts: it holds behaviour tests, so main's copy fails a tag that predates the behaviour. git checkout "$WORKFLOW_SHA" -- \ src/main/skills/skill-provider-runtime-roots.test.ts @@ -1069,7 +1068,6 @@ jobs: WORKFLOW_SHA: ${{ github.workflow_sha }} run: | git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" - # Why not skill-freshness-inventory.test.ts: it holds behaviour tests, so main's copy fails a tag that predates the behaviour. git checkout "$WORKFLOW_SHA" -- \ src/main/skills/skill-provider-runtime-roots.test.ts diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml deleted file mode 100644 index 9979384a5c9..00000000000 --- a/.github/workflows/skill-update-roundtrip.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Skill update round trip - -# Why the same paths on push as pull_request: GitHub evaluates each event's -# filters independently. The PR already skipped README-only changes, but the -# unfiltered `push` to main still started the 13-job matrix. Cancelling that -# run marks the default-branch tip failed, so the repo shows a red X. -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] - paths: &skill-roundtrip-paths - - 'skills/**' - - 'resources/skills/**' - - 'config/scripts/verify-skill-update-roundtrip.mjs' - - 'src/main/skills/skill-freshness-eligibility.ts' - - 'src/shared/agent-feature-install-commands.ts' - - 'src/shared/skill-freshness.ts' - - '.github/workflows/skill-update-roundtrip.yml' - # Why unfiltered: GitHub ignores `paths` on merge_group. We do not run a merge - # queue today; if one is added, gate the matrix on a path job first. - merge_group: - push: - branches: - - main - paths: *skill-roundtrip-paths - -concurrency: - group: skill-roundtrip-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -permissions: - contents: read - -jobs: - roundtrip: - if: github.event_name != 'pull_request' || github.event.pull_request.draft != true - strategy: - fail-fast: false - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - # Why: `pinned` runs the skills CLI version Orca ships (SKILLS_CLI_VERSION). - 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 - id: checkout - with: - persist-credentials: false - - uses: actions/setup-node@v6 - id: node - with: - node-version-file: package.json - check-latest: true - - name: Verify pinned symlink placement with autocrlf false - if: ${{ !cancelled() && matrix.skills-cli == 'pinned' && steps.checkout.outcome == 'success' && steps.node.outcome == 'success' }} - env: - GITHUB_TOKEN: ${{ github.token }} - SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - SKILL_UPDATE_REF: ${{ github.event.pull_request.head.sha || github.sha }} - run: >- - node config/scripts/verify-skill-update-roundtrip.mjs - --autocrlf=false - --shape=symlink - - - name: Verify pinned symlink placement with autocrlf true - if: ${{ !cancelled() && matrix.skills-cli == 'pinned' && steps.checkout.outcome == 'success' && steps.node.outcome == 'success' }} - env: - GITHUB_TOKEN: ${{ github.token }} - SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - SKILL_UPDATE_REF: ${{ github.event.pull_request.head.sha || github.sha }} - run: >- - node config/scripts/verify-skill-update-roundtrip.mjs - --autocrlf=true - --shape=symlink - - - name: Verify pinned copy placement with autocrlf false - if: ${{ !cancelled() && matrix.skills-cli == 'pinned' && steps.checkout.outcome == 'success' && steps.node.outcome == 'success' }} - env: - GITHUB_TOKEN: ${{ github.token }} - SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - SKILL_UPDATE_REF: ${{ github.event.pull_request.head.sha || github.sha }} - run: >- - node config/scripts/verify-skill-update-roundtrip.mjs - --autocrlf=false - --shape=copy - - - name: Verify pinned copy placement with autocrlf true - if: ${{ !cancelled() && matrix.skills-cli == 'pinned' && steps.checkout.outcome == 'success' && steps.node.outcome == 'success' }} - env: - GITHUB_TOKEN: ${{ github.token }} - SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - SKILL_UPDATE_REF: ${{ github.event.pull_request.head.sha || github.sha }} - run: >- - node config/scripts/verify-skill-update-roundtrip.mjs - --autocrlf=true - --shape=copy - - - name: Preview latest CLI with symlink placement and autocrlf false - if: matrix.skills-cli == 'latest' - env: - GITHUB_TOKEN: ${{ github.token }} - SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - SKILL_UPDATE_REF: ${{ github.event.pull_request.head.sha || github.sha }} - run: >- - node config/scripts/verify-skill-update-roundtrip.mjs - --cli=latest - --autocrlf=false - --shape=symlink diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 8848b97caac..650a0f58b1b 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -23,6 +23,7 @@ export const CLI_MAIN_ENTRY_NAMES = [ 'gitlab/project-ref-parser', 'orca-profiles/profile-index-store', 'claude-accounts/keychain', + 'skills/bundled-skill-install/cli-entry', 'macos-tree-clone', ...[ 'access', diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 8b451f50811..50df0ec0465 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -382,6 +382,7 @@ module.exports = { 'out/main/macos-tree-clone.js', 'out/main/orca-profiles/profile-index-store.js', 'out/main/persistence/profile-state/**', + 'out/main/skills/bundled-skill-install/cli-entry.js', 'out/main/startup/http1-compatibility-marker.js', 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index d7c44c3317b..600318392d5 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -4,7 +4,6 @@ src/cli/handlers/account.ts src/cli/handlers/core.ts src/cli/handlers/interactive-login-interruption.ts -src/cli/handlers/skills.ts src/cli/runtime/launch.ts src/main/agent-hooks/managed-hook-owner-identity.ts src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -98,7 +97,6 @@ src/main/pty/windows-environment-path.ts src/main/rate-limits/gemini-cli-oauth-extractor.ts src/main/runtime/windows-default-route-interfaces.ts src/main/runtime/windows-mobile-firewall.ts -src/main/skills/skill-update-run.ts src/main/ssh/connection/ssh-connection.ts src/main/ssh/ssh-proxy-command.ts src/main/ssh/system-transport/system-ssh-command.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index d40d8e13fdc..547694b3387 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -251,7 +251,7 @@ const childResults = await Promise.all( const result = await buildOrcadEntry(SERVER_OUT_FILE) const cliResult = await buildOrcadCli(join(OUT_DIR, ORCAD_CLI_ENTRY_FILENAME)) const { version: cliVersion } = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) -// Why commit: `orca skills install|update` pins official skills to it, as the desktop CLI does. +// Why commit: the stamp keys one serve-supervisor snapshot per build. const cliPackage = { type: 'commonjs', private: true, diff --git a/config/scripts/ci-closed-pr-cancellation.test.mjs b/config/scripts/ci-closed-pr-cancellation.test.mjs index 64546709e46..7019bb9c32c 100644 --- a/config/scripts/ci-closed-pr-cancellation.test.mjs +++ b/config/scripts/ci-closed-pr-cancellation.test.mjs @@ -100,7 +100,6 @@ it('looks up exact PR groups without branch or head-SHA inference', async () => 'ci-cache-warmup-platforms-123', 'pi-owner-runtime-123', 'git-command-termination-123', - 'skill-roundtrip-pull_request-123', 'ci-cache-warmup-123' ]) expect( diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 485f79078d5..4e151c4979f 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -335,7 +335,6 @@ "config/scripts/verify-packaged-plugin-resources.test.mjs": 71, "config/scripts/verify-packaged-windows-agent-hook.test.mjs": 38, "config/scripts/verify-release-required-assets.test.mjs": 37, - "config/scripts/verify-skill-update-roundtrip.test.mjs": 51, "config/scripts/verify-skills-cli-runtime.test.mjs": 194, "config/scripts/verify-windows-inner-signature.test.mjs": 56, "config/scripts/verify-wsl-e2e-participation.test.mjs": 38, @@ -5450,7 +5449,6 @@ "src/main/skills/discovery-filter-sharing.test.ts": 242, "src/main/skills/discovery-source-filter-order.test.ts": 94, "src/main/skills/discovery.test.ts": 194, - "src/main/skills/skill-bundle-artifacts.test.ts": 285, "src/main/skills/skill-bundle-creation.test.ts": 686, "src/main/skills/skill-bundle-install-service.test.ts": 565, "src/main/skills/skill-bundle-observability-summary.test.ts": 31, @@ -5479,9 +5477,6 @@ "src/main/skills/skill-discovery-wsl-plugins.test.ts": 74, "src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 136, "src/main/skills/skill-discovery-wsl.test.ts": 85, - "src/main/skills/skill-freshness-eligibility.test.ts": 48, - "src/main/skills/skill-freshness-inventory-limits.test.ts": 292, - "src/main/skills/skill-freshness-inventory.test.ts": 951, "src/main/skills/skill-install-destinations.test.ts": 57, "src/main/skills/skill-install-discovery-verification.test.ts": 98, "src/main/skills/skill-install-lock-release.test.ts": 31, @@ -5504,7 +5499,6 @@ "src/main/skills/skill-placement-copy-drift.test.ts": 250, "src/main/skills/skill-placement-reconciliation.test.ts": 263, "src/main/skills/skill-placement-transaction.test.ts": 301, - "src/main/skills/skill-plugin-cache-scan.test.ts": 352, "src/main/skills/skill-process-contention-child.test.ts": 285, "src/main/skills/skill-process-contention.integration.test.ts": 226, "src/main/skills/skill-process-termination-child.test.ts": 289, @@ -5520,8 +5514,6 @@ "src/main/skills/skill-scan-coalescer.test.ts": 49, "src/main/skills/skill-share-preparation-service.test.ts": 425, "src/main/skills/skill-transaction-startup-recovery.test.ts": 521, - "src/main/skills/skill-update-outcome.test.ts": 48, - "src/main/skills/skill-update-run.test.ts": 160, "src/main/skills/skill-upload-process-restart-child.test.ts": 210, "src/main/skills/skill-upload-process-restart.integration.test.ts": 232, "src/main/skills/skill-upload-session-admission-regression.test.ts": 263, @@ -8542,8 +8534,6 @@ "src/renderer/src/components/skills/skill-delete-copy.test.ts": 101, "src/renderer/src/components/skills/skill-delete-selection.test.ts": 260, "src/renderer/src/components/skills/skill-description-length.test.ts": 35, - "src/renderer/src/components/skills/skill-freshness-grouping.test.ts": 53, - "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts": 59, "src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 275, "src/renderer/src/components/skills/skill-install-provider-groups.test.ts": 49, "src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 41, diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 60231fa4872..77e02836261 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -202,6 +202,7 @@ describe('Electron Vite output contract', () => { 'persistence/profile-state/legacy-json/profile-state-recovery', 'persistence/profile-state/profile-state-recovery-command', 'persistence/profile-state/profile-state-storage-classification', + 'skills/bundled-skill-install/cli-entry', 'startup/http1-compatibility-marker' ]) { expect(input).toHaveProperty(name) @@ -216,6 +217,9 @@ describe('Electron Vite output contract', () => { expect(electronBuilderConfig.asarUnpack).toContain( 'out/main/startup/http1-compatibility-marker.js' ) + expect(electronBuilderConfig.asarUnpack).toContain( + 'out/main/skills/bundled-skill-install/cli-entry.js' + ) }) it('externalizes packaged dependencies but bundles self-contained main dependencies', async () => { diff --git a/config/scripts/pr-package-test-paths.mjs b/config/scripts/pr-package-test-paths.mjs index e08632a7bff..9a3a0015fcc 100644 --- a/config/scripts/pr-package-test-paths.mjs +++ b/config/scripts/pr-package-test-paths.mjs @@ -94,7 +94,9 @@ export const WINDOWS_BOUNDARY_TESTS = [ 'src/main/ipc/preflight-provider-command-selection.test.ts', 'src/main/ipc/preflight-runnable-local-cli.test.ts', 'src/main/managed-server/install/remote-node-runtime-store-windows.test.ts', - 'src/main/skills/skill-windows-rename-contention.integration.test.ts' + 'src/main/skills/skill-windows-rename-contention.integration.test.ts', + 'src/main/skills/bundled-skill-install/local-host.test.ts', + 'src/main/skills/bundled-skill-install/plan-selection.test.ts' ] export const WINDOWS_PACKAGE_TESTS = [ diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs index 2182d0cf99f..2371274e883 100644 --- a/config/scripts/run-node-server-tests.mjs +++ b/config/scripts/run-node-server-tests.mjs @@ -1,7 +1,8 @@ // Runs the headless-server suites under the pinned Node (design D4/D4a), not the host's. import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' -import { globSync, readFileSync } from 'node:fs' +import { globSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' import { ORCAD_CLI_ENTRY_FILENAME, ORCAD_SERVER_ENTRY_FILENAME, @@ -18,7 +19,6 @@ import { currentTarget } from './server-build-target.mjs' import { UNIT_INCLUDE } from './ci-unit-files.mjs' import { runProcessSync } from '@orca/process-host' import { describeProcessFailure } from './process-failure-message.mjs' -import { resolveBuildSourceCommit } from './build-source-commit.mjs' import { CROSS_RUNTIME_TEST_PATHS, nodeServerTestPaths, @@ -82,33 +82,7 @@ if (artifact) { ) process.stdout.write(`${JSON.stringify({ target, ...response })}\n`) - const source = `https://github.com/stablyai/orca/tree/${resolveBuildSourceCommit()}` - const cli = runProcessSync({ - program: runtimePath, - args: [ - join(packageDir, ORCAD_CLI_ENTRY_FILENAME), - 'skills', - 'install', - '--skill', - 'orca-cli', - '--agent', - 'universal', - '--dry-run' - ], - cwd: root, - env, - timeoutMs: 90_000 - }) - if ( - cli.code !== 0 || - cli.timedOut || - cli.outputTruncated || - !cli.stdout.split('\n')[0].split(' ').includes(source) - ) { - throw new Error( - `Bundled CLI skills source did not match ${source}: ${describeProcessFailure(cli)}` - ) - } + assertBundledCliPlansSkillInstall() } run(runtimePath, [ join(root, 'node_modules/vitest/vitest.mjs'), @@ -130,3 +104,50 @@ function defaultTestArgs() { .filter((path) => !path.endsWith('.electron.test.ts') && !skipped.has(path)) .sort() } + +/** The server's own CLI must plan an install of Orca's bundled skills, offline and npx-free. */ +function assertBundledCliPlansSkillInstall() { + // Why the real path: the planner reports real paths, and macOS tmpdir is a /var symlink. + const home = realpathSync(mkdtempSync(join(tmpdir(), 'orcad-skills-home-'))) + const { ORCA_CLI_CWD: _forwarded, ...hostEnv } = env + try { + const cli = runProcessSync({ + program: runtimePath, + args: [ + join(packageDir, ORCAD_CLI_ENTRY_FILENAME), + 'skills', + 'install', + '--skill', + 'orca-cli', + '--agent', + 'universal', + '--dry-run', + '--json' + ], + cwd: home, + env: { ...hostEnv, HOME: home, USERPROFILE: home }, + timeoutMs: 90_000 + }) + const failure = describeProcessFailure(cli) + if (cli.code !== 0 || cli.timedOut || cli.outputTruncated) { + throw new Error(`Bundled CLI skills dry-run failed: ${failure}`) + } + const result = JSON.parse(cli.stdout) + const ops = result.plan?.skills?.find((skill) => skill.name === 'orca-cli')?.ops ?? [] + if ( + result.executed !== false || + !/^[0-9a-f]{64}$/.test(result.planDigest) || + ops.length === 0 || + !ops.every((op) => op.path.startsWith(home)) + ) { + throw new Error(`Bundled CLI did not plan orca-cli into ${home}: ${cli.stdout}`) + } + if (readdirSync(home).length > 0) { + throw new Error( + `Bundled CLI skills dry-run wrote into ${home}: ${readdirSync(home).join(', ')}` + ) + } + } finally { + rmSync(home, { recursive: true, force: true }) + } +} diff --git a/config/scripts/verify-skill-update-roundtrip.mjs b/config/scripts/verify-skill-update-roundtrip.mjs deleted file mode 100644 index 6a4573cc269..00000000000 --- a/config/scripts/verify-skill-update-roundtrip.mjs +++ /dev/null @@ -1,180 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - realpath, - rm, - symlink, - writeFile -} from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'node:path' -import process from 'node:process' -import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs' - -function option(name) { - return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3) -} - -// Why: the exact rail Orca runs for both install and update, pinned to this PR's commit. -// A unit test holds it, and this pinned skills CLI, equal to the shared builder's argv. -const PINNED_SKILLS_CLI_VERSION = '1.7.2' - -export function roundTripAddArgs(source, ref, skillName, cliVersion = PINNED_SKILLS_CLI_VERSION) { - return [ - `skills@${cliVersion}`, - 'add', - `https://github.com/${source}/tree/${ref}`, - '--skill', - skillName, - '--global', - '--agent', - 'claude-code', - '--agent', - 'universal', - '-y' - ] -} - -async function main() { - // Why: defaults to the version Orca pins; `--cli=latest` previews the next release. - const cliVersion = option('cli') ?? PINNED_SKILLS_CLI_VERSION - const autocrlf = option('autocrlf') - const placement = option('shape') - // Why: PR refs are untrusted workflow input. Keep them out of the generated shell - // command and pass them to Node through the environment. - const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE - const ref = option('ref') ?? process.env.SKILL_UPDATE_REF - if ( - (autocrlf !== 'true' && autocrlf !== 'false') || - (placement !== 'symlink' && placement !== 'copy') || - !source || - !ref || - !/^[^/\s]+\/[^/\s]+$/.test(source) || - // Why: Orca pins to a full commit, never a branch or tag. - !/^[0-9a-f]{40}$/.test(ref) - ) { - throw new Error( - 'Usage: verify-skill-update-roundtrip.mjs [--cli=] --autocrlf=true|false --shape=symlink|copy --source= --ref=' - ) - } - - const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-')) - const home = path.join(sandbox, 'home') - const stateHome = path.join(home, '.state') - const targetName = 'orca-cli' - const controlName = 'orchestration' - const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8')) - - function currentDigest(name) { - const skill = manifest.skills.find((entry) => entry.name === name) - if (!skill) { - throw new Error(`Current manifest is missing ${name}`) - } - return skill.packageDigest - } - - async function packageDigestAt(directory) { - return packageDigest(await collectPackageFiles(await realpath(directory))) - } - - // Why: the copies #26841 stranded: CRLF bytes from no known release, edited, with a - // sha256 lock hash. The pinned add must replace them all the same. - async function seedStalePlacement(name) { - const canonical = path.join(home, '.agents', 'skills', name) - await mkdir(canonical, { recursive: true }) - const markdown = await readFile(path.join('skills', name, 'SKILL.md'), 'utf8') - await writeFile( - path.join(canonical, 'SKILL.md'), - `${markdown}\nA line no Orca release ever shipped.\n`.replace(/\n/g, '\r\n') - ) - const providerRoot = path.join(home, '.claude', 'skills') - const provider = path.join(providerRoot, name) - await mkdir(providerRoot, { recursive: true }) - await (placement === 'copy' - ? cp(canonical, provider, { recursive: true }) - : symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir')) - return { canonical, provider } - } - - function execSkills(args) { - const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx' - const cliArgs = ['--yes', ...args] - execFileSync( - executable, - process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs, - { - cwd: process.cwd(), - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - CODEX_HOME: path.join(home, '.codex'), - CLAUDE_CONFIG_DIR: path.join(home, '.claude'), - XDG_STATE_HOME: stateHome, - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'core.autocrlf', - GIT_CONFIG_VALUE_0: autocrlf, - CI: '1' - }, - stdio: 'inherit' - } - ) - } - - try { - const target = await seedStalePlacement(targetName) - const control = await seedStalePlacement(controlName) - const controlBefore = await packageDigestAt(control.canonical) - const lock = { - version: 3, - skills: Object.fromEntries( - [targetName, controlName].map((name) => [ - name, - { - source, - sourceType: 'github', - sourceUrl: `https://github.com/${source}.git`, - skillPath: `skills/${name}/SKILL.md`, - skillFolderHash: 'f'.repeat(64) - } - ]) - ) - } - const lockPath = path.join(stateHome, 'skills', '.skill-lock.json') - await mkdir(path.dirname(lockPath), { recursive: true }) - await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`) - - execSkills(roundTripAddArgs(source, ref, targetName, cliVersion)) - - const expected = currentDigest(targetName) - if ((await packageDigestAt(target.canonical)) !== expected) { - throw new Error(`${targetName} canonical placement did not become the pinned content`) - } - if ((await packageDigestAt(target.provider)) !== expected) { - throw new Error(`${targetName} claude placement did not become the pinned content`) - } - if (placement === 'symlink' && !(await lstat(target.provider)).isSymbolicLink()) { - throw new Error(`${targetName} provider alias was replaced with an independent copy`) - } - const recorded = JSON.parse(await readFile(lockPath, 'utf8')).skills?.[targetName]?.source - if (typeof recorded !== 'string' || recorded.toLowerCase() !== source.toLowerCase()) { - throw new Error(`${targetName} lock source is ${recorded}, not ${source}`) - } - if ((await packageDigestAt(control.canonical)) !== controlBefore) { - throw new Error('Targeted add changed the non-targeted control skill') - } - if (placement === 'symlink' && !(await lstat(control.provider)).isSymbolicLink()) { - throw new Error('Targeted add changed the non-targeted control topology') - } - } finally { - await rm(sandbox, { recursive: true, force: true }) - } -} - -if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { - await main() -} diff --git a/config/scripts/verify-skill-update-roundtrip.test.mjs b/config/scripts/verify-skill-update-roundtrip.test.mjs deleted file mode 100644 index 43be0fcacaf..00000000000 --- a/config/scripts/verify-skill-update-roundtrip.test.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildAgentFeatureSkillInstallArgs } from '../../src/shared/agent-feature-install-commands' -import { roundTripAddArgs } from './verify-skill-update-roundtrip.mjs' - -const COMMIT = 'a'.repeat(40) - -describe('skill update round trip', () => { - it('runs exactly the shared builder’s unattended add, pinned skills CLI included', () => { - expect(roundTripAddArgs('stablyai/orca', COMMIT, 'orca-cli')).toEqual( - buildAgentFeatureSkillInstallArgs(['orca-cli'], { - sourceCommit: COMMIT, - yes: true, - agents: ['claude-code', 'universal'] - }) - ) - }) -}) diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index 3d6958389e0..2fc8c37cc49 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -45,8 +45,6 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ 'config/scripts/run-ssh-staged-upload-reliability.mjs', 'config/scripts/run-terminal-ibus-hangul-e2e.mjs', 'config/scripts/run-terminal-scale-perf-e2e.mjs', - // Routes its shim through an explicit `cmd.exe /d /s /c`, which is the correct form. - 'config/scripts/verify-skill-update-roundtrip.mjs', 'tests/tools/benchmarks/startup-time-bench.mjs', 'tests/tools/benchmarks/worktree-deletion-dev-bench.mjs', 'tests/tools/repro-terminal-send-submit.mjs' diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index ef019209446..ae2325bc2cf 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -14,7 +14,6 @@ "./scripts/happy-dom-animation-cancel.ts", "./scripts/vitest-node-runtime-pool.ts", "../src/main/**/*", - "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts", "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index ab20ec7cad6..02a08586de1 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -349,7 +349,7 @@ orca skills install --all --dry-run orca skills update --all ``` -`install` / `update` shell out to the same `npx skills` commands Settings uses. They do not contact the Orca runtime. See [Orca skills](/docs/cli/skills#keep-skills-up-to-date). +`install` / `update` run Orca's own skill installer on the machine that runs them, the same planner the app's **Update skills** uses. They do not contact the Orca runtime or need `npx`. See [Orca skills](/docs/cli/skills#keep-skills-up-to-date). ## Account (host-local runtime) diff --git a/docs/site/content/docs/cli/skills.mdx b/docs/site/content/docs/cli/skills.mdx index 3674c42f07f..cb3c09cf68c 100644 --- a/docs/site/content/docs/cli/skills.mdx +++ b/docs/site/content/docs/cli/skills.mdx @@ -1,6 +1,6 @@ --- title: Orca skills registry & MCP -description: Install Orca agent skills with npx skills add. Hybrid stubs stay thin; orca skills get serves the version-matched guide. Orca can update skills in the background. +description: Install Orca agent skills with npx skills add. Hybrid stubs stay thin; orca skills get serves the version-matched guide. Orca updates its own skills without npx. keywords: - Orca skills - npx skills add @@ -51,36 +51,33 @@ Add `--json` when an agent needs deterministic output for automation. `skills sh ## Keep skills up to date -When Orca ships a newer skill package than the one installed globally, the app can: +Orca updates its own skills itself, with no `npx`, Git, Node or network. A copy counts as Orca's untouched copy when its folder holds only `SKILL.md` and that file matches a version Orca has shipped. Only those copies are rewritten, in place (the shared `~/.agents/skills/` or a real folder in an agent's skills folder such as `~/.claude/skills/`). Anything else is left alone and reported: **You've changed this copy**, or **A different skill already uses this name**. Orca never deletes a folder it did not create. -- Show an **update available** nudge for skills Orca installed (and **Needs attention** when a copy is out of date somewhere the updater cannot safely rewrite). -- Open **Update skills** — list of placements and skip reasons, with a checkbox per skill. Orca's own skills start ticked; a same-named skill installed from somewhere else is marked **Not from Orca** and starts unticked. The selected skills are reinstalled headlessly (no embedded terminal) with **`npx --yes skills@1.7.2 add https://github.com/stablyai/orca/tree/ --skill … --global -y`**, pinned to the commit the running Orca build was made from (so it works on hourly, daily, RC and stable builds alike) and to a known skills CLI version. -- Keep the run in the **background**: closing the dialog does not cancel it. A status-bar segment shows progress (spinner while running, brief success check, persistent failure until you act). Click the segment to reopen the dialog. -- Surface freshness on **Settings → Agents** for installed skills (global install rail), including **Details** when a row needs review. +When this Orca build ships a newer version of a skill you installed, the app: -Manual equivalent (desktop Settings shows the same pinned form for the running build): +- Shows an **update available** nudge and badge. Update only touches locations that already hold the skill; it never adds the skill for another agent. +- Opens **Update skills**, which lists every location and what Update does there: **No change**, **Will update**, **Will link**, or **Left alone** with the reason. A copy left alone has a **Replace** choice (off by default); Replace first moves that copy to `~/.agents/.orca-skill-backups/`, then puts Orca's copy in its place. +- Runs exactly the plan you reviewed: if a folder changed after you opened the dialog, that skill reports **Changed since you reviewed it** and nothing is written there. +- Shows the result per skill, for example `Updated · 1 left alone: You've changed this copy`, and a status-bar segment while it runs. -```bash -npx skills@1.7.2 add https://github.com/stablyai/orca/tree/ --skill orca-cli --skill orchestration --global -``` +This covers skills installed on this computer. The app does not check WSL projects, SSH hosts or paired Orca servers yet; run `orca skills update` there. -On headless hosts (SSH, containers, CI, `orca serve`) with no Settings UI, use the local CLI wrappers — they resolve the same `npx` commands, add non-interactive flags, and do **not** need a running Orca runtime: +On headless hosts (SSH, containers, CI, `orca serve`) use the CLI. It runs the same planner on the machine that runs it and does **not** need a running Orca app: ```bash orca skills install # list installable names orca skills install --skill orca-cli --skill orchestration -orca skills install --skill orca-cli --agent claude-code,codex -orca skills install --all --dry-run +orca skills install --skill orca-cli --agent claude,codex +orca skills update --all --dry-run # what would change, and the plan digest orca skills update --all -orca skills update --skill orca-cli --dry-run +orca skills update --all --force # also replace copies left alone (backup kept) ``` -- Default scope is **global** (`--global`); pass `--local` for the current project only. -- `install` targets agents Orca detects on the host (plus the shared `.agents/skills` directory). Use `--agent [,…]` or `--agent universal` to override; if no agent is detected, `--agent` is required. -- `update` runs the same pinned `skills add` as `install`. With no agent detected it targets only `.agents/skills` instead of refusing. -- `--dry-run` prints the resolved command; `--json` is only valid with listing / `--dry-run`. - -Prefer the in-app updater when Orca offers it so the same global placements the app scanned get rewritten. Rows marked **Skipped** explain why that skill cannot be updated automatically (for example a read-only folder or a link pointing outside Orca's skill folders) — fix the placement, then re-check. +- Default scope is **global**; pass `--local` for the current project only (Orca links only into agent folders the project already has). +- Targets the agents Orca detects on the host plus the shared `.agents/skills` folder. Use `--agent [,…]` to choose (the skills CLI's keys such as `claude-code` still work), or `--agent universal` for the shared folder alone. +- `update` only touches locations that already hold the skill; `install` adds it, including for newly detected agents. +- `--dry-run` prints the plan and its digest without writing. A real run plans again and writes only while the plan still matches. +- `--json` works for dry runs and real runs. The exit code is 1 when a location failed or changed during the run; folders left alone are warnings. ## Install and delete from the Skills page diff --git a/src/cli/cli-version.test.ts b/src/cli/cli-version.test.ts index 6cbe6469846..b1d7615548e 100644 --- a/src/cli/cli-version.test.ts +++ b/src/cli/cli-version.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { readOrcaCliBuildCommit, readOrcaCliVersion } from './cli-version' +import { readOrcaCliVersion } from './cli-version' const temporaryDirectories: string[] = [] @@ -22,7 +22,6 @@ describe('CLI version', () => { ) expect(readOrcaCliVersion(runtimeDir)).toBe('1.4.178-rc.2') - expect(readOrcaCliBuildCommit(runtimeDir)).toBe('b'.repeat(40)) }) it('rejects missing, malformed, and non-string versions', async () => { diff --git a/src/cli/cli-version.ts b/src/cli/cli-version.ts index 961317f2f68..476e4dd32bf 100644 --- a/src/cli/cli-version.ts +++ b/src/cli/cli-version.ts @@ -1,22 +1,14 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' -function readCompiledOutputField(runtimeDir: string, field: 'version' | 'commit'): string | null { +// Node-mode CLI code cannot read the package metadata inside app.asar. +export function readOrcaCliVersion(runtimeDir = __dirname): string | null { try { const parsed: unknown = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8')) - const value = parsed && typeof parsed === 'object' && field in parsed ? parsed[field] : null + const value = + parsed && typeof parsed === 'object' && 'version' in parsed ? parsed.version : null return typeof value === 'string' && value.length > 0 ? value : null } catch { return null } } - -// Node-mode CLI code cannot read the package metadata inside app.asar. -export function readOrcaCliVersion(runtimeDir = __dirname): string | null { - return readCompiledOutputField(runtimeDir, 'version') -} - -/** The commit the CLI was built from, stamped into the same package boundary. */ -export function readOrcaCliBuildCommit(runtimeDir = __dirname): string | null { - return readCompiledOutputField(runtimeDir, 'commit') -} diff --git a/src/cli/handlers/bundled-skill-install-command.ts b/src/cli/handlers/bundled-skill-install-command.ts new file mode 100644 index 00000000000..a7b0047c3d5 --- /dev/null +++ b/src/cli/handlers/bundled-skill-install-command.ts @@ -0,0 +1,281 @@ +import { homedir } from 'node:os' +import { realpath } from 'node:fs/promises' +import type { CommandHandler } from '../dispatch' +import { RuntimeClientError } from '../runtime-client' +import { getDefaultUserDataPath } from '../runtime/metadata' +import { getRepeatedStringFlag } from '../flags' +import { writeStdoutLine } from '../stdout-line' +import { skillsCliAgentAliases } from '../skills-cli-agent-aliases' +import { loadCanonicalGuides, type BundledSkillGuide } from './bundled-skill-guide-table' +import type { + BundledSkillOp, + BundledSkillOpOutcome, + BundledSkillPlan +} from '../../shared/bundled-skill-plan' +import { isBundledSkillLeaveReason } from '../../shared/bundled-skill-plan' +import { + BUNDLED_SKILL_LEAVE_REASON_TEXT, + BUNDLED_SKILL_PLAN_OP_TEXT, + summarizeBundledSkillOutcomes +} from '../../shared/bundled-skill-summary' +import { + isSkillInstallProviderId, + SKILL_INSTALL_PROVIDERS, + type SkillInstallProviderId +} from '../../shared/skill-install-providers' +import { SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT } from '../../shared/skills-cli-agent-keys' +import { + installBundledSkills, + localBundledSkillInput, + previewBundledSkillInstall +} from '../../main/skills/bundled-skill-install/cli-entry' + +export type SkillMutationVerb = 'install' | 'update' + +const SHARED_FOLDER_ONLY = 'universal' + +// Why: earlier releases passed `--agent` straight to the skills CLI, so scripts still use its keys. +const SKILLS_CLI_KEY_ALIASES = skillsCliAgentAliases(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT) + +/** `undefined` means detect; `[]` is `--agent universal`, the shared folder alone. */ +function requestedAgents( + flags: Map +): SkillInstallProviderId[] | undefined { + const requested = flags.get('agent') + if (flags.has('agent') && typeof requested !== 'string') { + throw new RuntimeClientError('invalid_argument', 'Missing required --agent') + } + if (typeof requested !== 'string') { + return undefined + } + // Why one comma-separated value: `agent` is a single-value flag on other commands. + const values = requested + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + if (values.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing required --agent') + } + const agents = new Set() + for (const value of values) { + if (value === SHARED_FOLDER_ONLY) { + continue + } + const agent = isSkillInstallProviderId(value) ? value : SKILLS_CLI_KEY_ALIASES.get(value) + if (!agent) { + throw new RuntimeClientError( + 'invalid_argument', + `Unknown --agent value "${value}". Use one or more of: ` + + `${SKILL_INSTALL_PROVIDERS.map((provider) => provider.id).join(', ')}, ` + + `or ${SHARED_FOLDER_ONLY} for the shared .agents/skills folder alone.` + ) + } + agents.add(agent) + } + return [...agents] +} + +function resolveSelectedSkillNames( + flags: Map, + guides: BundledSkillGuide[] +): string[] { + const requestedSkills = getRepeatedStringFlag(flags, 'skill') + const selectAll = flags.get('all') === true + if (flags.has('skill') && requestedSkills.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing required --skill') + } + if (selectAll && requestedSkills.length > 0) { + throw new RuntimeClientError('invalid_argument', 'Use either --all or --skill, not both.') + } + if (selectAll) { + return guides.map((guide) => guide.name) + } + const guideByTopic = new Map( + guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide])) + ) + const canonicalNames = new Set() + for (const requested of requestedSkills) { + const guide = guideByTopic.get(requested) + if (!guide) { + throw new RuntimeClientError( + 'invalid_argument', + `Unknown skill "${requested}". Available skills: ${guides.map((g) => g.name).join(', ')}` + ) + } + canonicalNames.add(guide.name) + } + return [...canonicalNames].sort() +} + +function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[]): string { + return [ + `Choose one or more skills to ${verb}:`, + ...skillNames.map((name) => ` ${name}`), + '', + `Usage: orca skills ${verb} --skill [--skill ...]`, + ` or: orca skills ${verb} --all` + ].join('\n') +} + +function leaveText(reason: string | undefined): string { + const text = + reason && isBundledSkillLeaveReason(reason) + ? BUNDLED_SKILL_LEAVE_REASON_TEXT[reason] + : 'This copy was kept as it is' + return `Update skipped: ${text}` +} + +function describePlanOp(op: BundledSkillOp): string { + return op.kind === 'leave' ? leaveText(op.reason) : BUNDLED_SKILL_PLAN_OP_TEXT[op.kind] +} + +function describeOutcomeOp(op: BundledSkillOpOutcome): string | null { + switch (op.status) { + case 'done': + return op.kind === 'leave' ? leaveText(op.reason) : null + case 'changed': + return 'Changed since it was planned; nothing was written. Run it again to re-plan.' + case 'skipped': + return 'Skipped: the shared copy it depends on did not land.' + case 'failed': + return `Failed: ${op.error ?? 'unknown error'}` + } +} + +function hasReplaceableLeave(plan: BundledSkillPlan): boolean { + return plan.skills.some((entry) => + entry.ops.some((op) => op.kind === 'leave' && op.replaceable === true) + ) +} + +function describePlan(plan: BundledSkillPlan): string[] { + return plan.skills.flatMap((entry) => [ + entry.name, + ...entry.ops.map((op) => ` ${op.path}: ${describePlanOp(op)}`) + ]) +} + +async function workspaceDirectory(): Promise { + // Why the real path: the planner dedupes targets by real path, so the root it is given must + // be the same spelling every time this folder is planned. + return realpath(process.cwd()) +} + +export function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler { + return async ({ flags, json }) => { + const guides = await loadCanonicalGuides() + const skillNames = resolveSelectedSkillNames(flags, guides) + if (skillNames.length === 0) { + const names = guides.map((guide) => guide.name) + writeStdoutLine( + json + ? JSON.stringify({ availableSkills: names }, null, 2) + : formatSkillSelectionHelp(verb, names) + ) + return + } + + // Why before target resolution: the answer belongs to the other machine, and agents + // detected here would be the wrong host's. + if (process.env.ORCA_CLI_CWD) { + throw new RuntimeClientError( + 'invalid_environment', + `orca skills ${verb} writes to the machine that runs it, but this shell forwards ` + + `orca to the Orca host. Run the same orca skills ${verb} command on the machine ` + + "you want it on, where it can detect that host's agents." + ) + } + + const local = flags.get('local') === true + const input = await localBundledSkillInput({ + userDataPath: getDefaultUserDataPath(), + destination: local + ? { + scope: 'workspace', + homeDirectory: homedir(), + workspaceDirectory: await workspaceDirectory() + } + : { scope: 'global', homeDirectory: homedir() }, + agents: requestedAgents(flags), + selection: { + names: skillNames, + ...(flags.get('force') === true ? { force: 'all' as const } : {}), + // Why: update never adds a skill; install is the verb that does. + installedOnly: verb === 'update' + } + }) + const plan = await previewBundledSkillInstall(input) + const planned = new Set(plan.skills.map((entry) => entry.name)) + // Why --all is exempt: it names every bundled skill, so skipping absent ones is expected. + const notInstalled = + verb === 'update' && flags.get('all') !== true + ? skillNames.filter((name) => !planned.has(name)) + : [] + const notInstalledLines = notInstalled.map( + (name) => + `${name}: not installed, so skipped. Install it with: orca skills install --skill ${name}` + ) + const forceHint = hasReplaceableLeave(plan) + ? ['', 'Rerun with --force to replace a copy that was skipped; Orca keeps a backup of it.'] + : [] + + if (flags.get('dry-run') === true) { + writeStdoutLine( + json + ? JSON.stringify( + { + executed: false, + scope: plan.scope, + planDigest: plan.planDigest, + plan, + notInstalled + }, + null, + 2 + ) + : [ + ...describePlan(plan), + ...notInstalledLines, + ...forceHint, + '', + `Plan digest: ${plan.planDigest}`, + `Rerun without --dry-run to ${verb} now.` + ].join('\n') + ) + return + } + + const result = await installBundledSkills(input, plan.planDigest) + const summary = summarizeBundledSkillOutcomes(result) + process.exitCode = summary.exitCode + if (json) { + writeStdoutLine( + JSON.stringify( + { + executed: true, + scope: plan.scope, + planDigest: plan.planDigest, + plan, + result, + summary, + notInstalled + }, + null, + 2 + ) + ) + return + } + const lines = result.skills.flatMap((outcome, index) => [ + `${outcome.name}: ${summary.skills[index].line}`, + ...outcome.ops.flatMap((op) => { + const text = describeOutcomeOp(op) + return text ? [` ${op.path}: ${text}`] : [] + }) + ]) + if (lines.length === 0 && notInstalledLines.length === 0) { + lines.push('No Orca skills are installed here, so there is nothing to update.') + } + writeStdoutLine([...lines, ...notInstalledLines, ...forceHint].join('\n')) + } +} diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index 63498808749..e1ee86a8f24 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -1,262 +1,8 @@ -import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' -import { RuntimeClientError } from '../runtime-client' -import { getRepeatedStringFlag } from '../flags' import { writeStdoutLine } from '../stdout-line' -import { loadCanonicalGuides, type BundledSkillGuide } from './bundled-skill-guide-table' +import { loadCanonicalGuides } from './bundled-skill-guide-table' import { SKILL_GUIDE_GET_HANDLER } from './skill-guide-get' -import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import { - getSpawnArgsForWindows, - UnsafeWindowsBatchArgumentsError, - WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL -} from '../../shared/windows-batch-spawn' -import { isUsableSkillsCliAgentKey } from '../../shared/skills-cli-agent-keys' -import { - detectSkillsCliAgentKeys, - detectSkillsCliUpdateAgentKeys -} from '../../shared/skills-cli-agent-detection' -import { - buildAgentFeatureSkillInstallArgs, - SKILLS_CLI_ENV_OVERRIDES -} from '../../shared/agent-feature-install-commands' -import { readOrcaCliBuildCommit } from '../cli-version' - -function resolveSelectedSkillNames( - flags: Map, - guides: BundledSkillGuide[] -): string[] { - const requestedSkills = getRepeatedStringFlag(flags, 'skill') - const selectAll = flags.get('all') === true - if (flags.has('skill') && requestedSkills.length === 0) { - throw new RuntimeClientError('invalid_argument', 'Missing required --skill') - } - if (selectAll && requestedSkills.length > 0) { - throw new RuntimeClientError('invalid_argument', 'Use either --all or --skill, not both.') - } - if (!selectAll && requestedSkills.length === 0) { - return [] - } - if (selectAll) { - return guides.map((guide) => guide.name) - } - const availableTopics = guides.map((guide) => guide.name).join(', ') - const guideByTopic = new Map( - guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide])) - ) - const canonicalNames = new Set() - for (const requested of requestedSkills) { - const guide = guideByTopic.get(requested) - if (!guide) { - throw new RuntimeClientError( - 'invalid_argument', - `Unknown skill "${requested}". Available skills: ${availableTopics}` - ) - } - canonicalNames.add(guide.name) - } - return [...canonicalNames].sort() -} - -/** PATH with the resolved npx's own directory first, so its `env node` shebang resolves. */ - -function runNpxSkills(args: string[]): Promise { - return new Promise((resolve, reject) => { - // Why: a bare PATH lookup misses nvm/fnm/volta installs, and hardcoding - // `npx.cmd` both misses `npx.exe` shims and hides a missing npx behind - // cmd.exe's own exit code, so the friendly error below never fires. - const resolved = resolveCliCommand('npx') - let spawnCmd: string - let spawnArgs: string[] - try { - ;({ spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolved, args)) - } catch (error) { - // Why: the guard rejects cmd metacharacters, and only the resolved npx - // path can carry them here — a username like `A&B` puts them in it. - if (!(error instanceof UnsafeWindowsBatchArgumentsError)) { - reject(error) - return - } - reject( - new RuntimeClientError( - 'invalid_environment', - `Cannot run npx from "${resolved}": the path contains characters cmd.exe would ` + - `reinterpret. Install Node.js somewhere without ${WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL} in the path.` - ) - ) - return - } - // Why: npx is an `#!/usr/bin/env node` script, so resolving it off PATH is - // not enough — without node alongside it the child exits 127 with no - // 'error' event and the message below never fires. - const child = spawn(spawnCmd, spawnArgs, { - stdio: 'inherit', - // Why the shared helper: it verifies the sibling node actually exists, - // skips a bare-name fallback whose dirname is '.', and writes the key - // Windows will really read — all of which a local join got wrong. - env: withCliRuntimeOnPath(resolved, { ...process.env, ...SKILLS_CLI_ENV_OVERRIDES }) - }) - // Why: a missing npx/Node on a headless host surfaces as a raw spawn ENOENT; - // wrap it so the CLI reports an actionable message like every other failure here. - child.once('error', (error) => { - const detail = error instanceof Error ? error.message : String(error) - reject( - new RuntimeClientError( - 'invalid_environment', - `Could not run npx: ${detail}. Install Node.js and ensure npx is on PATH.` - ) - ) - }) - child.once('exit', (code, signal) => { - resolve(typeof code === 'number' ? code : signal ? 1 : 0) - }) - }) -} - -type SkillMutationVerb = 'install' | 'update' - -function resolveAgentKeys(verb: SkillMutationVerb, flags: Map): string[] { - const requested = flags.get('agent') - if (flags.has('agent') && typeof requested !== 'string') { - throw new RuntimeClientError('invalid_argument', 'Missing required --agent') - } - if (typeof requested === 'string') { - // Why: one comma-separated value rather than a repeatable flag — `agent` is a - // single-value flag on other commands and the repeatable set is process-wide, - // so making it repeatable here would change how those parse a second --agent. - const keys = [ - ...new Set( - requested - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean) - ) - ] - // Why: a value like "," parses to nothing. Falling through to detection would - // be surprising, and emitting no --agent would restore the all-agents install. - if (keys.length === 0) { - throw new RuntimeClientError('invalid_argument', 'Missing required --agent') - } - const unusable = keys.find((key) => !isUsableSkillsCliAgentKey(key)) - if (unusable !== undefined) { - // Why: the skills CLI drops a value starting with `-`, which leaves it with - // no target and installs into every agent it knows. - throw new RuntimeClientError( - 'invalid_argument', - `Invalid --agent value "${unusable}". Pass agent names such as claude-code, ` + - 'codex, or universal.' - ) - } - return keys - } - if (verb === 'update') { - return detectSkillsCliUpdateAgentKeys() - } - const detected = detectSkillsCliAgentKeys() - if (detected.length > 0) { - return detected - } - // Why: without --agent, `skills add -y` falls into its own zero-detected branch - // and installs into every agent it knows (~75), creating config directories for - // agents this host does not have. Say so instead. - throw new RuntimeClientError( - 'invalid_environment', - 'No coding agent detected on this host, so there is no install target. Pass ' + - '--agent [,...] to choose targets explicitly — --agent universal ' + - 'writes only the shared .agents/skills directory that Orca reads.' - ) -} - -function buildNpxSkillsArgs(skillNames: string[], global: boolean, agents: string[]): string[] { - const sourceCommit = readOrcaCliBuildCommit() - if (!sourceCommit) { - throw new RuntimeClientError( - 'invalid_environment', - 'Could not read the commit this Orca CLI was built from, so there is no source to install skills from.' - ) - } - // Why: a cold package cache makes bare `npx` prompt before it will fetch - // `skills`, which strands an unattended host just like the picker does. - return [ - '--yes', - ...buildAgentFeatureSkillInstallArgs(skillNames, { sourceCommit, global, yes: true, agents }) - ] -} - -/** Render the exact argv a real run spawns, so --dry-run can never drift from it. */ -function formatNpxCommand(args: string[]): string { - return `npx ${args.join(' ')}` -} - -function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[]): string { - return [ - `Choose one or more skills to ${verb}:`, - ...skillNames.map((name) => ` ${name}`), - '', - `Usage: orca skills ${verb} --skill [--skill ...]`, - ` or: orca skills ${verb} --all` - ].join('\n') -} - -function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler { - return async ({ flags, json }) => { - const guides = await loadCanonicalGuides() - const skillNames = resolveSelectedSkillNames(flags, guides) - - if (skillNames.length === 0) { - const names = guides.map((guide) => guide.name) - writeStdoutLine( - json - ? JSON.stringify({ availableSkills: names }, null, 2) - : formatSkillSelectionHelp(verb, names) - ) - return - } - - // Why: this runs before target resolution because the answer belongs to the - // other machine — agents detected here would be the wrong host's, and a host - // that detects none would hide the forwarding problem behind that error. - if (process.env.ORCA_CLI_CWD) { - throw new RuntimeClientError( - 'invalid_environment', - `orca skills ${verb} writes to the machine that runs it, but this shell forwards ` + - `orca to the Orca host. Run the same orca skills ${verb} command on the machine ` + - "you want it on, where it can detect that host's agents." - ) - } - - const global = flags.get('local') !== true - // Why: update is the same pinned `skills add`; `skills update` follows GitHub HEAD, - // which runs ahead of the build this CLI judges freshness against. - const npxArgs = buildNpxSkillsArgs(skillNames, global, resolveAgentKeys(verb, flags)) - const command = formatNpxCommand(npxArgs) - const dryRun = flags.get('dry-run') === true - - if (dryRun) { - writeStdoutLine( - json - ? JSON.stringify({ command, skills: skillNames, global, executed: false }, null, 2) - : `${command}\n\nRerun without --dry-run to ${verb} now.` - ) - return - } - - if (json) { - // Why: a real run inherits npx's own stdout so progress stays visible live; - // that stream is not JSON, so --json can't be honored here. - throw new RuntimeClientError( - 'invalid_argument', - `orca skills ${verb} --json only supports --dry-run. Real ${verb}s stream ` + - "npx's own output, which isn't JSON." - ) - } - - // Why: stdio is inherited for the child below, so this status line must go to - // stderr — stdout is npx's own output, not this command's JSON channel. - process.stderr.write(`Running: ${command}\n`) - process.exitCode = await runNpxSkills(npxArgs) - } -} +import { createSkillMutationHandler } from './bundled-skill-install-command' export const SKILL_HANDLERS: Record = { 'skills list': async ({ json }) => { diff --git a/src/cli/skills-cli-agent-aliases.test.ts b/src/cli/skills-cli-agent-aliases.test.ts new file mode 100644 index 00000000000..c9d79a7aafa --- /dev/null +++ b/src/cli/skills-cli-agent-aliases.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT } from '../shared/skills-cli-agent-keys' +import { skillsCliAgentAliases } from './skills-cli-agent-aliases' + +describe('skills CLI agent aliases', () => { + it('resolves shipped collisions canonically regardless of table order', () => { + for (const entries of [ + Object.entries(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT), + Object.entries(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT).toReversed() + ]) { + const aliases = skillsCliAgentAliases(Object.fromEntries(entries)) + + expect(aliases.get('claude-code')).toBe('claude') + expect(aliases.get('opencode')).toBe('opencode') + expect(aliases.get('gemini-cli')).toBe('gemini') + } + }) + + it.each(['new-key', 'codex'])( + 'rejects a new collision for %s without a canonical choice', + (key) => { + expect(() => skillsCliAgentAliases({ claude: key, codex: key })).toThrow( + `Skills CLI agent key "${key}" needs an explicit canonical provider.` + ) + } + ) +}) diff --git a/src/cli/skills-cli-agent-aliases.ts b/src/cli/skills-cli-agent-aliases.ts new file mode 100644 index 00000000000..91b26e9560f --- /dev/null +++ b/src/cli/skills-cli-agent-aliases.ts @@ -0,0 +1,34 @@ +import { + isSkillInstallProviderId, + type SkillInstallProviderId +} from '../shared/skill-install-providers' + +const CANONICAL_PROVIDER_BY_SKILLS_CLI_KEY = new Map([ + ['claude-code', 'claude'], + ['opencode', 'opencode'] +]) + +export function skillsCliAgentAliases( + agentKeys: Readonly> +): ReadonlyMap { + const providersByKey = new Map() + for (const [agent, key] of Object.entries(agentKeys)) { + if (!key || !isSkillInstallProviderId(agent)) { + continue + } + const providers = providersByKey.get(key) ?? [] + providers.push(agent) + providersByKey.set(key, providers) + } + + const aliases = new Map() + for (const [key, providers] of providersByKey) { + const canonical = + providers.length === 1 ? providers[0] : CANONICAL_PROVIDER_BY_SKILLS_CLI_KEY.get(key) + if (!canonical || !providers.includes(canonical)) { + throw new Error(`Skills CLI agent key "${key}" needs an explicit canonical provider.`) + } + aliases.set(key, canonical) + } + return aliases +} diff --git a/src/cli/skills-install.test.ts b/src/cli/skills-install.test.ts new file mode 100644 index 00000000000..7f8a1c40582 --- /dev/null +++ b/src/cli/skills-install.test.ts @@ -0,0 +1,320 @@ +import { createHash } from 'node:crypto' +import { + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + readlink, + realpath, + rm, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BundledSkillInstallResult, BundledSkillPlan } from '../shared/bundled-skill-plan' + +const NAME = 'orca-cli' +const OLD_TEXT = `---\nname: ${NAME}\ndescription: Old\n---\n\nOld body\n` +const NEW_TEXT = `---\nname: ${NAME}\ndescription: New\n---\n\nNew body\n` +const EDITED_TEXT = `---\nname: ${NAME}\n---\nmine\n` +const sha = (text: string) => createHash('sha256').update(text).digest('hex') + +const { detectAgentsMock } = vi.hoisted(() => ({ detectAgentsMock: vi.fn() })) + +// Why: the real installer runs against a temp home; only the shipped bytes and the +// host's agent probe are pinned so the test does not depend on this machine. +vi.mock('../main/skills/bundled-skill-install/bundled-skill-sources', () => ({ + BUNDLED_SKILL_SOURCES: [ + { name: NAME, text: NEW_TEXT, releasedDigests: [sha(OLD_TEXT), sha(NEW_TEXT)] } + ] +})) +vi.mock('../main/preflight/installed-agent-detection', () => ({ + detectInstalledAgentsWithShellPathHydration: detectAgentsMock +})) +// Why: a held lock should fail fast here instead of waiting out the real 5s default. +vi.mock('../main/skills/bundled-skill-install/local-host', async (importOriginal) => { + const actual = await importOriginal() + return { + localBundledSkillInput: async (...args: Parameters) => ({ + ...(await actual.localBundledSkillInput(...args)), + lockTimeoutMs: 50 + }) + } +}) + +import type * as LocalHost from '../main/skills/bundled-skill-install/local-host' +import { main } from './index' +import { acquireSkillInstallLock, skillInstallLockPath } from '../main/skills/skill-install-lock' + +type Paths = { root: string; home: string; userData: string; shared: string; claude: string } + +let paths: Paths + +async function makeHome(): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-cli-skills-'))) + const home = join(root, 'home') + await mkdir(home, { recursive: true }) + return { + root, + home, + userData: join(root, 'user-data'), + shared: join(home, '.agents', 'skills', NAME), + claude: join(home, '.claude', 'skills', NAME) + } +} + +async function writeCopy(path: string, text: string): Promise { + await mkdir(path, { recursive: true }) + await writeFile(join(path, 'SKILL.md'), text) +} + +async function skillText(path: string): Promise { + return readFile(join(path, 'SKILL.md'), 'utf8').catch(() => null) +} + +async function run(args: string[]): Promise { + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + try { + await main(['skills', ...args], paths.root) + return stdout.mock.calls.map((call) => String(call[0])).join('') + } finally { + stdout.mockRestore() + } +} + +async function runJson(args: string[]): Promise { + const parsed: T = JSON.parse(await run([...args, '--json'])) + return parsed +} + +type DryRunJson = { + executed: false + planDigest: string + plan: BundledSkillPlan + notInstalled: string[] +} +type RunJson = { + executed: true + planDigest: string + plan: BundledSkillPlan + result: BundledSkillInstallResult + summary: { ok: boolean; exitCode: number; skills: { line: string }[] } + notInstalled: string[] +} + +describe('orca skills install/update', () => { + beforeEach(async () => { + paths = await makeHome() + vi.stubEnv('HOME', paths.home) + vi.stubEnv('USERPROFILE', paths.home) + vi.stubEnv('ORCA_USER_DATA_PATH', paths.userData) + vi.stubEnv('CLAUDE_CONFIG_DIR', '') + vi.stubEnv('OPENCLAUDE_CONFIG_DIR', '') + vi.stubEnv('ORCA_CLI_CWD', '') + detectAgentsMock.mockReset() + detectAgentsMock.mockResolvedValue(['claude']) + process.exitCode = undefined + }) + + afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + process.exitCode = undefined + await rm(paths.root, { recursive: true, force: true }) + }) + + it('prints the plan and its digest for --dry-run and writes nothing', async () => { + await writeCopy(paths.shared, OLD_TEXT) + + const text = await run(['update', '--skill', NAME, '--agent', 'claude', '--dry-run']) + + // Why no Claude line: update never adds the skill for an agent that does not have it. + expect(text).toContain(`${NAME}\n ${paths.shared}: Update\n\n`) + expect(text).toMatch(/Plan digest: [0-9a-f]{64}\nRerun without --dry-run to update now\.\n$/u) + expect(await skillText(paths.shared)).toBe(OLD_TEXT) + expect(await lstat(paths.claude).catch(() => null)).toBeNull() + expect(process.exitCode).toBeUndefined() + }) + + it('executes exactly the ops the dry run printed', async () => { + await writeCopy(paths.shared, OLD_TEXT) + const args = ['install', '--skill', NAME, '--agent', 'claude'] + + const dry = await runJson([...args, '--dry-run']) + const real = await runJson(args) + + expect(dry.executed).toBe(false) + expect(real.planDigest).toBe(dry.planDigest) + expect(real.result.skills.map((skill) => skill.ops.map((op) => [op.path, op.kind]))).toEqual( + dry.plan.skills.map((skill) => skill.ops.map((op) => [op.path, op.kind])) + ) + expect(real.result.skills[0].ops.every((op) => op.status === 'done')).toBe(true) + expect(real.summary).toMatchObject({ ok: true, exitCode: 0 }) + expect(await skillText(paths.shared)).toBe(NEW_TEXT) + expect(await realpath(paths.claude)).toBe(paths.shared) + expect(process.exitCode).toBe(0) + }) + + it('prints one result line per skill for a real run', async () => { + await writeCopy(paths.shared, OLD_TEXT) + + const text = await run(['update', '--skill', NAME, '--agent', 'universal']) + + expect(text).toBe(`${NAME}: Updated\n`) + }) + + it('never installs a skill on update; install adds it', async () => { + const text = await run(['update', '--skill', NAME, '--agent', 'claude']) + + expect(text).toContain(`${NAME}: not installed, so skipped.`) + expect(await lstat(paths.shared).catch(() => null)).toBeNull() + + await run(['install', '--skill', NAME, '--agent', 'claude']) + expect(await skillText(paths.shared)).toBe(NEW_TEXT) + expect(await realpath(paths.claude)).toBe(paths.shared) + }) + + it('leaves an edited copy alone, then replaces it with a backup for --force', async () => { + await writeCopy(paths.shared, EDITED_TEXT) + + const left = await run(['update', '--skill', NAME, '--agent', 'universal']) + expect(left).toContain('Update skipped: Skill edited outside Orca') + expect(left).toContain('1 skipped: Skill edited outside Orca') + expect(left).toContain('Rerun with --force to replace a copy that was skipped') + expect(left).not.toContain('left alone') + expect(process.exitCode).toBe(0) + expect(await skillText(paths.shared)).toBe(EDITED_TEXT) + + const forced = await runJson([ + 'update', + '--skill', + NAME, + '--agent', + 'universal', + '--force' + ]) + expect(forced.plan.skills[0].ops.map((op) => op.kind)).toEqual(['replace']) + expect(await skillText(paths.shared)).toBe(NEW_TEXT) + const backupPath = forced.result.skills[0].ops[0].backupPath ?? '' + expect(backupPath.startsWith(join(paths.home, '.agents', '.orca-skill-backups'))).toBe(true) + expect(await skillText(backupPath)).toBe(EDITED_TEXT) + }) + + it('exits 1 when the run cannot apply the plan', async () => { + await writeCopy(paths.shared, OLD_TEXT) + // Why: another install holding the target's lock is a run that wrote nothing. + const release = await acquireSkillInstallLock({ + path: skillInstallLockPath(join(paths.userData, 'skill-installs'), paths.shared) + }) + try { + const real = await runJson(['update', '--skill', NAME, '--agent', 'universal']) + expect(real.result.skills[0].status).toBe('busy') + expect(real.summary).toMatchObject({ ok: false, exitCode: 1 }) + } finally { + await release() + } + expect(process.exitCode).toBe(1) + expect(await skillText(paths.shared)).toBe(OLD_TEXT) + }, 20_000) + + it('counts a skill with multiple skipped folders once in text and JSON summaries', async () => { + await writeCopy(paths.shared, EDITED_TEXT) + await writeCopy(paths.claude, EDITED_TEXT) + + const args = ['update', '--skill', NAME, '--agent', 'claude'] + const text = await run(args) + expect(text).toContain(`${NAME}: No change · 1 skipped: Skill edited outside Orca\n`) + expect(text).toContain(` ${paths.shared}: Update skipped: Skill edited outside Orca`) + expect(text).toContain(` ${paths.claude}: Update skipped: Skill edited outside Orca`) + const result = await runJson(args) + expect(result.summary).toMatchObject({ ok: true, changedCount: 0, leftAloneCount: 1 }) + }) + + it("accepts the skills CLI's agent keys and universal for the shared folder alone", async () => { + await writeCopy(paths.shared, NEW_TEXT) + + const aliased = await runJson([ + 'install', + '--skill', + NAME, + '--agent', + 'claude-code', + '--dry-run' + ]) + expect(aliased.plan.skills[0].ops.map((op) => [op.path, op.kind])).toEqual([ + [paths.shared, 'keep'], + [paths.claude, 'link'] + ]) + const shared = await runJson([ + 'install', + '--skill', + NAME, + '--agent', + 'universal', + '--dry-run' + ]) + expect(shared.plan.skills[0].ops.map((op) => op.path)).toEqual([paths.shared]) + expect(detectAgentsMock).not.toHaveBeenCalled() + }) + + it('installs for OpenClaude when its Orca agent id is requested directly', async () => { + await run(['install', '--skill', NAME, '--agent', 'openclaude']) + + expect(await realpath(join(paths.home, '.openclaude', 'skills', NAME))).toBe(paths.shared) + expect(await skillText(paths.shared)).toBe(NEW_TEXT) + expect(await lstat(paths.claude).catch(() => null)).toBeNull() + expect(process.exitCode).toBe(0) + }) + + it.each([ + ['claude-code', '.claude', '.openclaude'], + ['openclaude', '.openclaude', '.claude'] + ])('updates an existing agent-only copy for --agent %s', async (agent, folder, otherFolder) => { + const installed = join(paths.home, folder, 'skills', NAME) + await writeCopy(installed, OLD_TEXT) + + const result = await runJson(['update', '--skill', NAME, '--agent', agent]) + + expect(result.notInstalled).toEqual([]) + expect(result.result.skills[0].ops.map((op) => op.path)).toEqual([installed]) + expect(await skillText(installed)).toBe(NEW_TEXT) + expect(await lstat(join(paths.home, otherFolder, 'skills', NAME)).catch(() => null)).toBeNull() + expect(await lstat(paths.shared).catch(() => null)).toBeNull() + expect(process.exitCode).toBe(0) + }) + + it('detects agents when --agent is omitted', async () => { + await writeCopy(paths.shared, NEW_TEXT) + + const plan = await runJson(['install', '--skill', NAME, '--dry-run']) + + expect(detectAgentsMock).toHaveBeenCalled() + expect(plan.plan.skills[0].ops.map((op) => op.path)).toEqual([paths.shared, paths.claude]) + }) + + it('rejects an unknown --agent value', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', NAME, '--agent', 'nope'], paths.root) + + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Unknown --agent value "nope"') + }) + + it('plans the current project for --local, linking only into agent folders it has', async () => { + const project = join(paths.root, 'project') + await mkdir(join(project, '.claude', 'skills'), { recursive: true }) + vi.spyOn(process, 'cwd').mockReturnValue(project) + + await run(['install', '--skill', NAME, '--agent', 'claude,droid', '--local']) + + const projectShared = join(project, '.agents', 'skills', NAME) + expect(await skillText(projectShared)).toBe(NEW_TEXT) + expect(await realpath(join(project, '.claude', 'skills', NAME))).toBe(projectShared) + expect((await readlink(join(project, '.claude', 'skills', NAME))).startsWith('/')).toBe(false) + expect(await readdir(project)).toEqual(['.agents', '.claude']) + expect(await lstat(paths.shared).catch(() => null)).toBeNull() + }) +}) diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 111115f8baa..4867c7009eb 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,56 +1,16 @@ -import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { delimiter, join } from 'node:path' -import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution' -import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../shared/windows-batch-spawn' -import { SKILLS_CLI_VERSION } from '../shared/agent-feature-install-commands' -const COMMIT = '0123456789abcdef0123456789abcdef01234567' -const PINNED_ADD = [ - '--yes', - `skills@${SKILLS_CLI_VERSION}`, - 'add', - `https://github.com/stablyai/orca/tree/${COMMIT}` -] -const DETECTED_AGENTS = ['--agent', 'claude-code', '--agent', 'universal'] - -const { - detectCommandsMock, - guideModuleLoadMock, - readOrcaCliBuildCommitMock, - resolveCliCommandMock, - runtimeClientConstructorMock, - spawnMock -} = vi.hoisted(() => ({ - detectCommandsMock: vi.fn(() => new Set(['claude'])), +const { guideModuleLoadMock, previewMock, runtimeClientConstructorMock } = vi.hoisted(() => ({ guideModuleLoadMock: vi.fn(), - readOrcaCliBuildCommitMock: vi.fn<() => string | null>(() => null), - resolveCliCommandMock: vi.fn(() => 'npx'), - runtimeClientConstructorMock: vi.fn(), - spawnMock: vi.fn() + previewMock: vi.fn(), + runtimeClientConstructorMock: vi.fn() })) -// Why: agent detection probes the real machine, so pin it or every install -// assertion depends on what the test runner happens to have installed. -vi.mock('../shared/local-agent-install-dir-detection', () => ({ - detectCommandsInInstallDirs: detectCommandsMock -})) - -// Why: override only the npx lookup so the real Windows .cmd rail still runs. -vi.mock('../shared/node-cli-command-resolution', async (importOriginal) => ({ - ...(await importOriginal()), - resolveCliCommand: resolveCliCommandMock -})) - -// Why: the install pins to the build's commit; a test run has no compiled package boundary. -vi.mock('./cli-version', () => ({ - readOrcaCliBuildCommit: readOrcaCliBuildCommitMock -})) - -vi.mock('node:child_process', () => ({ - spawn: spawnMock +// Why: these cases stop before planning; the installer itself is covered in skills-install.test.ts. +vi.mock('../main/skills/bundled-skill-install/cli-entry', () => ({ + localBundledSkillInput: previewMock, + previewBundledSkillInstall: previewMock, + installBundledSkills: previewMock })) vi.mock('./bundled-skill-guides.js', () => { @@ -113,13 +73,7 @@ describe('orca skills CLI', () => { beforeEach(() => { vi.restoreAllMocks() runtimeClientConstructorMock.mockClear() - resolveCliCommandMock.mockReset() - resolveCliCommandMock.mockReturnValue('npx') - detectCommandsMock.mockReset() - detectCommandsMock.mockReturnValue(new Set(['claude'])) - spawnMock.mockReset() - readOrcaCliBuildCommitMock.mockReset() - readOrcaCliBuildCommitMock.mockReturnValue(COMMIT) + previewMock.mockReset() process.exitCode = undefined }) @@ -280,7 +234,6 @@ describe('orca skills CLI', () => { '' ].join('\n') ) - expect(spawnMock).not.toHaveBeenCalled() expect(runtimeClientConstructorMock).not.toHaveBeenCalled() }) @@ -301,7 +254,6 @@ describe('orca skills CLI', () => { expect(process.exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith('Use either --all or --skill, not both.') - expect(spawnMock).not.toHaveBeenCalled() }) it('rejects an unknown --skill name', async () => { @@ -313,7 +265,6 @@ describe('orca skills CLI', () => { expect(errorSpy).toHaveBeenCalledWith( 'Unknown skill "missing". Available skills: alpha, gamma, zeta' ) - expect(spawnMock).not.toHaveBeenCalled() }) it('rejects --skill without a value', async () => { @@ -323,227 +274,6 @@ describe('orca skills CLI', () => { expect(process.exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith('--skill requires a value; it was passed with none.') - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('rejects --json for a real (non-dry-run) install', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['skills', 'install', '--skill', 'alpha', '--json'], '/tmp/repo') - - expect(process.exitCode).toBe(1) - expect(logSpy).toHaveBeenCalledWith( - JSON.stringify( - { - id: 'local', - ok: false, - error: { - code: 'invalid_argument', - message: - "orca skills install --json only supports --dry-run. Real installs stream npx's " + - "own output, which isn't JSON." - }, - _meta: { runtimeId: null } - }, - null, - 2 - ) - ) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('prints the resolved install command without running it for --dry-run', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') - - expect(stdoutText(stdoutSpy)).toBe( - `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y\n\n` + - 'Rerun without --dry-run to install now.\n' - ) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('gives dry-run --json a stable schema', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main(['skills', 'install', '--skill', 'legacy-alpha', '--dry-run', '--json'], '/tmp/repo') - - expect(stdoutText(stdoutSpy)).toBe( - `${JSON.stringify( - { - command: `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y`, - skills: ['alpha'], - global: true, - executed: false - }, - null, - 2 - )}\n` - ) - }) - - it('drops --global for --local in the dry-run command and JSON', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main(['skills', 'install', '--skill', 'alpha', '--local', '--dry-run'], '/tmp/repo') - - expect(stdoutText(stdoutSpy)).toBe( - `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --agent claude-code --agent universal -y\n\n` + - 'Rerun without --dry-run to install now.\n' - ) - - stdoutSpy.mockClear() - await main( - ['skills', 'install', '--skill', 'alpha', '--local', '--dry-run', '--json'], - '/tmp/repo' - ) - - expect(stdoutText(stdoutSpy)).toBe( - `${JSON.stringify( - { - command: `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --agent claude-code --agent universal -y`, - skills: ['alpha'], - global: false, - executed: false - }, - null, - 2 - )}\n` - ) - }) - - it('runs npx without --global for --local, with git messages forced to English', async () => { - // A translated git error stops the skills CLI falling back to fetching the commit (#28914). - vi.stubEnv('LC_ALL', 'de_DE.UTF-8') - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha', '--local'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - expect(spawnMock).toHaveBeenCalledWith( - 'npx', - [...PINNED_ADD, '--skill', 'alpha', ...DETECTED_AGENTS, '-y'], - expect.objectContaining({ stdio: 'inherit', env: expect.objectContaining({ LC_ALL: 'C' }) }) - ) - }) - - it('routes a resolved Windows .cmd shim through cmd.exe', async () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') - resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.cmd') - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - expect(spawnMock).toHaveBeenCalledWith( - 'C:\\Windows\\System32\\cmd.exe', - [ - '/d', - '/c', - 'C:\\Program Files\\nodejs\\npx.cmd', - '--yes', - `skills@${SKILLS_CLI_VERSION}`, - 'add', - `https://github.com/stablyai/orca/tree/${COMMIT}`, - '--skill', - 'alpha', - '--global', - ...DETECTED_AGENTS, - '-y' - ], - expect.objectContaining({ stdio: 'inherit' }) - ) - }) - - it('spawns a resolved npx path directly when it is not a .cmd shim', async () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.exe') - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - // Why: an .exe shim must stay a direct spawn so a missing npx still raises - // ENOENT on the child instead of hiding inside cmd.exe's own exit code. - expect(spawnMock.mock.calls[0]?.[0]).toBe('C:\\Program Files\\nodejs\\npx.exe') - expect(spawnMock.mock.calls[0]?.[1]?.[0]).toBe('--yes') - }) - - it('resolves a legacy topic alias to the canonical skill name for install', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'legacy-alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - expect(spawnMock).toHaveBeenCalledWith( - 'npx', - [...PINNED_ADD, '--skill', 'alpha', '--global', ...DETECTED_AGENTS, '-y'], - expect.objectContaining({ stdio: 'inherit' }) - ) - }) - - it('runs npx for --all and forwards its exit code', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--all'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 1, null) - await resultPromise - - expect(spawnMock).toHaveBeenCalledWith( - 'npx', - [ - ...PINNED_ADD, - '--skill', - 'alpha', - '--skill', - 'gamma', - '--skill', - 'zeta', - '--global', - ...DETECTED_AGENTS, - '-y' - ], - expect.objectContaining({ stdio: 'inherit' }) - ) - expect(process.exitCode).toBe(1) - }) - - it('propagates a spawn error as a nonzero exit', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('error', new Error('spawn npx ENOENT')) - await resultPromise - - expect(process.exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Could not run npx: spawn npx ENOENT. Install Node.js and ensure npx is on PATH.' - ) }) it('lists updatable skills when no --skill/--all is given', async () => { @@ -563,58 +293,6 @@ describe('orca skills CLI', () => { '' ].join('\n') ) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('prints the resolved update command without running it for --dry-run', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main(['skills', 'update', '--skill', 'legacy-alpha', '--dry-run'], '/tmp/repo') - - expect(stdoutText(stdoutSpy)).toBe( - `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y\n\n` + - 'Rerun without --dry-run to update now.\n' - ) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('selects project scope for --local on update', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main( - ['skills', 'update', '--skill', 'alpha', '--local', '--dry-run', '--json'], - '/tmp/repo' - ) - - expect(stdoutText(stdoutSpy)).toBe( - `${JSON.stringify( - { - command: `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --agent claude-code --agent universal -y`, - skills: ['alpha'], - global: false, - executed: false - }, - null, - 2 - )}\n` - ) - }) - - it('runs local updates with explicit project scope', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'update', '--skill', 'alpha', '--local'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - expect(spawnMock).toHaveBeenCalledWith( - 'npx', - [...PINNED_ADD, '--skill', 'alpha', ...DETECTED_AGENTS, '-y'], - expect.objectContaining({ stdio: 'inherit' }) - ) }) it('refuses a real run when the shell forwards orca to the Orca host', async () => { @@ -623,11 +301,9 @@ describe('orca skills CLI', () => { await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - // Why: the SSH relay and WSL bridge run argv on the Orca host, so a real - // install there would silently skip the machine the user is sitting on. - expect(spawnMock).not.toHaveBeenCalled() expect(process.exitCode).toBe(1) expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') + expect(previewMock).not.toHaveBeenCalled() }) it('refuses --dry-run through the host-forwarding shim too', async () => { @@ -636,250 +312,9 @@ describe('orca skills CLI', () => { await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') - // Why: the targets are resolved from THIS host's agents, so a command printed - // here would name the wrong machine's agents. Point at the target instead. - expect(spawnMock).not.toHaveBeenCalled() expect(process.exitCode).toBe(1) expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') - }) - - it('puts the resolved npx directory on the child PATH', async () => { - // Why a real directory with a real sibling node: pairing only fires when the - // node it would add actually exists, so a fictional path proves nothing. - const npxBin = mkdtempSync(join(tmpdir(), 'orca-npx-')) - for (const name of ['node', 'npx']) { - writeFileSync(join(npxBin, name), '') - chmodSync(join(npxBin, name), 0o755) - } - const child = createFakeChild() - spawnMock.mockReturnValue(child) - resolveCliCommandMock.mockReturnValue(join(npxBin, 'npx')) - vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - // Why: npx is an `env node` script, so an off-PATH npx exits 127 with no - // 'error' event unless node ships alongside it on the child's PATH. - const env = spawnMock.mock.calls[0]?.[2]?.env - // Why: the child still needs the inherited PATH and the rest of the parent - // environment; replacing it outright breaks git, node, HOME and npm config. - expect(env?.PATH).toBe(`${npxBin}${delimiter}/usr/bin${delimiter}/bin`) - expect(env?.HOME ?? env?.USERPROFILE).toBe(process.env.HOME ?? process.env.USERPROFILE) - }) - - it('leaves PATH untouched when no node ships beside the resolved npx', async () => { - // Why: prepending a directory that has no node buys nothing and would shadow - // the caller's own ordering for every other binary the child resolves. - const npxBin = mkdtempSync(join(tmpdir(), 'orca-npx-bare-')) - writeFileSync(join(npxBin, 'npx'), '') - chmodSync(join(npxBin, 'npx'), 0o755) - const child = createFakeChild() - spawnMock.mockReturnValue(child) - resolveCliCommandMock.mockReturnValue(join(npxBin, 'npx')) - vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - expect(spawnMock.mock.calls[0]?.[2]?.env?.PATH).toBe(`/usr/bin${delimiter}/bin`) - }) - - it('reports a Windows npx path cmd.exe would reinterpret', async () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') - resolveCliCommandMock.mockReturnValue('C:\\Users\\A&B\\npx.cmd') - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - - expect(spawnMock).not.toHaveBeenCalled() - expect(process.exitCode).toBe(1) - expect(String(errorSpy.mock.calls[0]?.[0])).toContain('cmd.exe would reinterpret') - // Why: remediation advice that names the wrong characters is unactionable. - expect(String(errorSpy.mock.calls[0]?.[0])).toContain(WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL) - }) - - it('runs npx from a Program Files (x86) install instead of refusing it', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') - const npx = 'C:\\Program Files (x86)\\nodejs\\npx.cmd' - resolveCliCommandMock.mockReturnValue(npx) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - expect(spawnMock.mock.calls[0]?.[0]).toBe('C:\\Windows\\System32\\cmd.exe') - expect(spawnMock.mock.calls[0]?.[1]?.slice(0, 3)).toEqual(['/d', '/c', npx]) - }) - - it('never puts the current directory on the child PATH when npx is unresolvable', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - // Why: resolveCliCommand returns the bare name when it finds nothing, and - // dirname('npx') is '.', which would run ./npx out of the caller's checkout. - resolveCliCommandMock.mockReturnValue('npx') - vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - // Why: assert the constructed value, not the ambient one — a dev PATH with a - // trailing separator carries its own '' entry and would fake a failure here. - expect(spawnMock.mock.calls[0]?.[2]?.env?.PATH).toBe(`/usr/bin${delimiter}/bin`) - }) - - it('refuses to install when Orca detects no agent, instead of targeting them all', async () => { - detectCommandsMock.mockReturnValue(new Set()) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - - // Why: `skills add -y` with nothing detected installs into every agent it - // knows (~75), creating config dirs for agents the host does not have. - expect(spawnMock).not.toHaveBeenCalled() - expect(process.exitCode).toBe(1) - expect(String(errorSpy.mock.calls[0]?.[0])).toContain('No coding agent detected') - }) - - it('honours an explicit --agent list without probing the host', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - detectCommandsMock.mockReturnValue(new Set()) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main( - ['skills', 'install', '--skill', 'alpha', '--agent', 'codex, claude-code ,codex'], - '/tmp/repo' - ) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - const argv = spawnMock.mock.calls[0]?.[1] ?? [] - const agents = argv.filter((_: string, i: number) => argv[i - 1] === '--agent') - // Why: trimmed and de-duplicated, and detection is not consulted at all. - expect(agents).toEqual(['codex', 'claude-code']) - expect(detectCommandsMock).not.toHaveBeenCalled() - }) - - it('maps detected agents onto the skills CLI namespace, not Orca ids', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - detectCommandsMock.mockReturnValue(new Set(['claude', 'cursor-agent', 'acli'])) - - await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') - - // Why: `skills add` exits 1 on an unknown --agent, and the ids differ — - // Orca's `claude` is `claude-code` and its `rovo` is `rovodev`. - expect(stdoutText(stdoutSpy)).toContain( - '--agent claude-code --agent cursor --agent rovodev --agent universal' - ) - }) - - it('updates with the same pinned add and agent targets as install', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main(['skills', 'install', '--skill', 'alpha', '--dry-run', '--json'], '/tmp/repo') - await main(['skills', 'update', '--skill', 'alpha', '--dry-run', '--json'], '/tmp/repo') - - const [install, update] = stdoutSpy.mock.calls.map((call) => JSON.parse(String(call[0]))) - // Why: `skills update` follows GitHub HEAD, which runs ahead of this release. - expect(update.command).toBe(install.command) - expect(update.command).toContain(`add https://github.com/stablyai/orca/tree/${COMMIT}`) - }) - - it('honours an explicit --agent list for an update', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main( - ['skills', 'update', '--skill', 'alpha', '--agent', 'universal', '--dry-run'], - '/tmp/repo' - ) - - expect(stdoutText(stdoutSpy)).toContain( - `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent universal -y` - ) - }) - - it('never refuses an update on a bare host; it targets the shared folder', async () => { - detectCommandsMock.mockReturnValue(new Set()) - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main(['skills', 'update', '--skill', 'alpha', '--dry-run'], '/tmp/repo') - - expect(stdoutText(stdoutSpy)).toContain( - `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent universal -y` - ) - }) - - it('refuses to run when the CLI cannot read its own release version', async () => { - readOrcaCliBuildCommitMock.mockReturnValue(null) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await main(['skills', 'update', '--skill', 'alpha', '--dry-run'], '/tmp/repo') - - expect(spawnMock).not.toHaveBeenCalled() - expect(process.exitCode).toBe(1) - expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Could not read the commit') - }) - - it.each([ - ['a bare --agent', ['skills', 'install', '--skill', 'alpha', '--agent']], - ['an empty --agent', ['skills', 'install', '--skill', 'alpha', '--agent', '']], - ['a separator-only --agent', ['skills', 'install', '--skill', 'alpha', '--agent', ' , ,']] - ])('rejects %s instead of installing to every agent', async (_label, argv) => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await main(argv, '/tmp/repo') - - // Why: an --agent that resolves to nothing must not fall back to detection or - // emit no --agent at all — the latter restores the ~75-agent install. - expect(spawnMock).not.toHaveBeenCalled() - expect(process.exitCode).toBe(1) - expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Missing required --agent') - }) - - it.each([ - ['a dash-leading value', ['skills', 'install', '--skill', 'alpha', '--agent', '-y']], - ['an inline dash value', ['skills', 'install', '--skill', 'alpha', '--agent=--copy']], - ['a value with a space', ['skills', 'install', '--skill', 'alpha', '--agent', 'a b']] - ])('rejects %s the skills CLI would silently drop', async (_label, argv) => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await main(argv, '/tmp/repo') - - // Why: the skills CLI drops such a value, leaving it with no target — the same - // all-agents install as omitting --agent entirely. - expect(spawnMock).not.toHaveBeenCalled() - expect(process.exitCode).toBe(1) - expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Invalid --agent value') - }) - - it('reports forwarding, not missing agents, when a forwarded host detects none', async () => { - vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt') - detectCommandsMock.mockReturnValue(new Set()) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - - // Why: resolving targets first would hide the forwarding problem behind a - // no-agent error about the wrong machine. - expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') + expect(previewMock).not.toHaveBeenCalled() }) it('documents --agent for skills install rather than the terminal-launch flag', async () => { @@ -891,120 +326,8 @@ describe('orca skills CLI', () => { expect(help).toContain('--agent ') expect(help).not.toContain('Launch a known TUI agent') }) - - it('accumulates a repeated --skill instead of keeping only the last one', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main( - ['skills', 'install', '--skill', 'zeta', '--skill', 'alpha'], - '/tmp/repo' - ) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - // Why: the documented primary invocation. Dropping 'skill' from the - // repeatable-flag set silently installs one skill instead of two. - expect(spawnMock).toHaveBeenCalledWith( - 'npx', - [...PINNED_ADD, '--skill', 'alpha', '--skill', 'zeta', '--global', ...DETECTED_AGENTS, '-y'], - expect.objectContaining({ stdio: 'inherit' }) - ) - }) - - it('collapses an alias and its canonical name into one --skill', async () => { - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - - await main( - ['skills', 'install', '--skill', 'alpha', '--skill', 'legacy-alpha', '--dry-run'], - '/tmp/repo' - ) - - expect(stdoutText(stdoutSpy)).toBe( - `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y\n\n` + - 'Rerun without --dry-run to install now.\n' - ) - expect(spawnMock).not.toHaveBeenCalled() - }) - - it('reports the command it is about to run on stderr', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 0, null) - await resultPromise - - // Why: stdout belongs to the child, so this record has to go to stderr. - expect(stderrSpy).toHaveBeenCalledWith( - `Running: npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y\n` - ) - }) - - it('runs the pinned add for update --all and forwards its exit code', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child) - vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const resultPromise = main(['skills', 'update', '--all'], '/tmp/repo') - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) - child.emit('exit', 2, null) - await resultPromise - - expect(spawnMock).toHaveBeenCalledWith( - 'npx', - [ - ...PINNED_ADD, - '--skill', - 'alpha', - '--skill', - 'gamma', - '--skill', - 'zeta', - '--global', - ...DETECTED_AGENTS, - '-y' - ], - expect.objectContaining({ stdio: 'inherit' }) - ) - expect(process.exitCode).toBe(2) - }) - - it('rejects --json for a real (non-dry-run) update', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['skills', 'update', '--skill', 'alpha', '--json'], '/tmp/repo') - - expect(process.exitCode).toBe(1) - expect(logSpy).toHaveBeenCalledWith( - JSON.stringify( - { - id: 'local', - ok: false, - error: { - code: 'invalid_argument', - message: - "orca skills update --json only supports --dry-run. Real updates stream npx's " + - "own output, which isn't JSON." - }, - _meta: { runtimeId: null } - }, - null, - 2 - ) - ) - expect(spawnMock).not.toHaveBeenCalled() - }) }) function stdoutText(spy: ReturnType): string { return spy.mock.calls.map((call) => String(call[0])).join('') } - -function createFakeChild(): EventEmitter { - return new EventEmitter() -} diff --git a/src/cli/specs/skills.ts b/src/cli/specs/skills.ts index aece3732d83..b9afef401f2 100644 --- a/src/cli/specs/skills.ts +++ b/src/cli/specs/skills.ts @@ -66,36 +66,33 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['skills', 'install'], - summary: 'Install bundled Orca skills via the community skills CLI', + summary: 'Install bundled Orca skills on this machine', usage: 'orca skills install [--skill ]... [--all] [--agent [,]] ' + - '[--local] [--dry-run] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'dry-run'], + '[--local] [--force] [--dry-run] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'force', 'dry-run'], notes: [ - 'Reads the bundled skill registry locally without contacting the Orca runtime.', - 'Resolves to the same `npx skills@ add /tree/ --skill ...` ' + - 'command used by Orca Settings, pinned to the commit this Orca build was made from, ' + - 'plus the non-interactive flags an unattended host needs (`npx --yes` and `-y`), ' + - 'then runs it and forwards its output and exit code.', - 'Installs globally (all projects, adds --global) by default. Use --local to install ' + - 'into the current project instead.', - 'Targets the coding agents Orca detects on this host, plus the shared ' + - '.agents/skills directory. Without an explicit target the skills CLI installs ' + - 'into every agent it knows about, which litters a host with config ' + - 'directories for agents it does not have.', - 'Use --agent [,...] to choose targets yourself, or --agent universal ' + - 'for the shared directory alone. Required when Orca detects no agent.', - 'Use --dry-run to print the resolved command without running it.', - 'With --json, the skill listing and --dry-run emit JSON; a real install streams ' + - "npx's own non-JSON output live and rejects --json.", - 'Omit --skill and --all to list installable skill names.', - 'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the install command from.' + 'Reads the bundled skill registry locally without contacting the Orca runtime, ' + + 'and needs no network, Node, Git or npx.', + 'Writes the copy this Orca build ships into the shared .agents/skills folder and ' + + 'links it into each detected agent\u2019s skills folder, the same way the app\u2019s ' + + 'Update skills does.', + 'Only copies Orca wrote and nobody changed are updated. A copy you changed, or a ' + + 'different skill with the same name, is skipped and is reported; --force replaces ' + + 'it after moving it to a backup under ~/.agents/.orca-skill-backups.', + 'Installs globally (all projects) by default. Use --local to install into the ' + + 'current project; it links only into agent folders the project already has.', + 'Targets the coding agents Orca detects on this host. Use --agent [,...] ' + + 'to choose them yourself, or --agent universal for the shared folder alone.', + 'Use --dry-run to print what would change and the plan digest without writing; a ' + + 'real run plans again and writes only if the plan still matches.', + 'Exits 1 when a location failed or changed while it ran; skipped folders are warnings.', + 'Omit --skill and --all to list installable skill names.' ], examples: [ - 'orca skills install', 'orca skills install --skill orca-cli --skill orchestration', 'orca skills install --skill orca-cli --local', - 'orca skills install --skill orca-cli --agent claude-code,codex', + 'orca skills install --skill orca-cli --agent claude,codex', 'orca skills install --all --dry-run' ] }, @@ -104,27 +101,22 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [ summary: 'Update Orca skills to the version this Orca build ships', usage: 'orca skills update [--skill ]... [--all] [--agent [,]] ' + - '[--local] [--dry-run] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'dry-run'], + '[--local] [--force] [--dry-run] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'force', 'dry-run'], notes: [ - 'Reads the bundled skill registry locally without contacting the Orca runtime.', - 'Runs the same pinned `npx skills add` command as `orca skills install`, which ' + - 'replaces each selected skill with the copy this Orca build ships.', - 'Updates the global install (all projects, adds --global) by default. Use --local to ' + - 'update the current project instead.', - 'Targets the same agents as `orca skills install`, or only the shared ' + - '.agents/skills directory when none is detected; use --agent to choose them yourself.', - 'Use --dry-run to print the resolved command without running it.', - 'With --json, the skill listing and --dry-run emit JSON; a real update streams ' + - "npx's own non-JSON output live and rejects --json.", - 'Omit --skill and --all to list updatable skill names.', - 'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the update command from.' + 'Runs the same planner as `orca skills install`, but only at locations that already ' + + 'hold the skill; it never adds a skill, or an existing skill for another agent.', + 'Updates each untouched copy where it is; other copies are skipped unless you ' + + 'pass --force. Use `orca skills install` to add a skill for a newly detected agent.', + 'Updates the global install by default. Use --local to update the current project.', + 'Use --dry-run to print what would change and the plan digest without writing.', + 'With --json, both dry runs and real runs print the plan, and real runs the result.', + 'Omit --skill and --all to list updatable skill names.' ], examples: [ - 'orca skills update', - 'orca skills update --skill orca-cli --skill orchestration', - 'orca skills update --skill orca-cli --local', - 'orca skills update --all --dry-run' + 'orca skills update --all', + 'orca skills update --skill orca-cli --dry-run', + 'orca skills update --all --force' ] } ] diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index f0695bd12bb..a1fb192c6d3 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -4,7 +4,6 @@ const { handleMock, discoverSkillsMock, discoverSkillObservationInWslMock, - inventorySkillFreshnessMock, getDefaultWslDistroMock, getWslHomeMock, parseWslPathMock @@ -12,7 +11,6 @@ const { handleMock: vi.fn(), discoverSkillsMock: vi.fn(), discoverSkillObservationInWslMock: vi.fn(), - inventorySkillFreshnessMock: vi.fn(), getDefaultWslDistroMock: vi.fn(), getWslHomeMock: vi.fn(), parseWslPathMock: vi.fn() @@ -40,8 +38,8 @@ vi.mock('../skills/skill-discovery-wsl', () => ({ discoverSkillObservationInWsl: discoverSkillObservationInWslMock })) -vi.mock('../skills/skill-freshness-inventory', () => ({ - inventorySkillFreshness: inventorySkillFreshnessMock +vi.mock('./skill-cloud-ipc-handlers', () => ({ + registerSkillCloudIpcHandlers: vi.fn() })) vi.mock('../wsl', () => ({ @@ -80,13 +78,6 @@ describe('registerSkillsHandlers', () => { parseWslPathMock.mockReturnValue(null) discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) discoverSkillObservationInWslMock.mockResolvedValue({ rows: [], sources: [], scannedAt: 1 }) - inventorySkillFreshnessMock.mockResolvedValue({ - schemaVersion: 1, - installations: [], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: 1 - }) getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice') Object.defineProperty(process, 'platform', { configurable: true, @@ -109,15 +100,21 @@ describe('registerSkillsHandlers', () => { return call[1] as (_event: unknown, target?: unknown) => Promise } - function getFreshnessHandler() { - registerSkillsHandlers(store as never) - const call = handleMock.mock.calls.find( - (entry: unknown[]) => entry[0] === 'skills:freshnessInventory' - ) + function handlerFor(channel: string) { + const call = handleMock.mock.calls.find((entry: unknown[]) => entry[0] === channel) if (!call) { - throw new Error('skills:freshnessInventory handler was not registered') + throw new Error(`${channel} handler was not registered`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ipcMain.handle mock records the listener registered for this channel. + return call[1] as (_event: unknown, value?: unknown) => Promise + } + + function bundledRuntime() { + return { + resolveSkillDiscoveryProviderRoots: vi.fn(), + planLocalBundledSkills: vi.fn().mockResolvedValue({ planned: true }), + executeLocalBundledSkills: vi.fn().mockResolvedValue({ executed: true }) } - return call[1] as (_event: unknown) => Promise } it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { @@ -246,12 +243,45 @@ describe('registerSkillsHandlers', () => { expect(discoverSkillsMock).not.toHaveBeenCalled() }) - it('keeps freshness inventory local and read-only over known repositories', async () => { - const handler = getFreshnessHandler() + it('registers the bundled-skill plan and execute channels only with a runtime', () => { + registerSkillsHandlers(store as never) + const channels = handleMock.mock.calls.map((entry: unknown[]) => entry[0]) + expect(channels).not.toContain('skills:bundledPlan') + expect(channels).not.toContain('skills:bundledExecute') + }) - await handler(null) + it('plans and executes the local host with the parsed request', async () => { + const runtime = bundledRuntime() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the members this code path reads. + registerSkillsHandlers(store as never, runtime as never) + const force = ['/home/u/.claude/skills/orca-cli'] + const planDigest = 'c'.repeat(64) - expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos }) + await expect(handlerFor('skills:bundledPlan')(null, undefined)).resolves.toEqual({ + planned: true + }) + await handlerFor('skills:bundledPlan')(null, { force }) + await expect(handlerFor('skills:bundledExecute')(null, { force, planDigest })).resolves.toEqual( + { executed: true } + ) + + expect(runtime.planLocalBundledSkills.mock.calls).toEqual([[{}], [{ force }]]) + expect(runtime.executeLocalBundledSkills).toHaveBeenCalledWith({ force, planDigest }) expect(getWslHomeMock).not.toHaveBeenCalled() }) + + it('rejects malformed bundled-skill requests before reaching the runtime', async () => { + const runtime = bundledRuntime() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the members this code path reads. + registerSkillsHandlers(store as never, runtime as never) + + await expect(handlerFor('skills:bundledPlan')(null, { force: 'all' })).rejects.toThrow() + await expect(handlerFor('skills:bundledPlan')(null, { names: ['x'] })).rejects.toThrow() + await expect(handlerFor('skills:bundledExecute')(null, {})).rejects.toThrow() + await expect( + handlerFor('skills:bundledExecute')(null, { planDigest: 'not-a-digest' }) + ).rejects.toThrow() + expect(runtime.planLocalBundledSkills).not.toHaveBeenCalled() + expect(runtime.executeLocalBundledSkills).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index 47c50b3f51d..21aa4ce8cf3 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -1,4 +1,3 @@ -import { BrowserWindow } from 'electron' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { @@ -6,16 +5,13 @@ import { type SkillDiscoveryResult, type SkillDiscoveryTarget } from '../../shared/skills' -import type { - SkillFreshnessInventory, - SkillUpdateRun, - SkillUpdateStartResult -} from '../../shared/skill-freshness' -import { inventorySkillFreshness } from '../skills/skill-freshness-inventory' -import { SkillUpdateRunner } from '../skills/skill-update-run' -import { skillUpdateFailedNames } from '../skills/skill-update-outcome' import { - clearSkillDiscoveryCaches, + BundledSkillExecuteRequestSchema, + BundledSkillPlanRequestSchema, + type BundledSkillInstallResult, + type BundledSkillPlan +} from '../../shared/bundled-skill-plan' +import { discoverSkillsOnTarget, resolveSkillDiscoveryTarget } from '../skills/skill-discovery-target' @@ -31,62 +27,26 @@ export function registerSkillsHandlers(store: Store, runtime?: OrcaRuntimeServic refresh: parsedTarget?.refresh === true }) } - const scanInventory = (): Promise => - // Why: the update command targets this machine's global homes. WSL and SSH - // inventories stay out until their installer rail has an equivalent proof. - inventorySkillFreshness({ repos: store.getRepos() }) - - const runner = new SkillUpdateRunner({ - sourceCommit: ORCA_BUILD_COMMIT, - // Why: per-skill outcomes come from re-hashing what is actually on disk, not - // from scraping stdout. - rescanOutdatedNames: async (names) => { - // Why: the run just rewrote skill packages on this host. Clients that never - // send `refresh` (older builds) would otherwise read a pre-run scan. - clearSkillDiscoveryCaches() - return skillUpdateFailedNames(names, (await scanInventory()).installations) - }, - onState: (run: SkillUpdateRun) => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('skills:updateRun', run) - } - } - } - }) handleMainWindowSkillIpc( 'skills:discover', async (_event, target?: SkillDiscoveryTarget): Promise => discover(target) ) - if (runtime) { - registerSkillCloudIpcHandlers(runtime, discover) + if (!runtime) { + return } + registerSkillCloudIpcHandlers(runtime, discover) + // Local host only; WSL and paired hosts plan Orca's skills on their own host. handleMainWindowSkillIpc( - 'skills:freshnessInventory', - async (): Promise => { - return scanInventory() - } + 'skills:bundledPlan', + async (_event, value: unknown): Promise => + runtime.planLocalBundledSkills(BundledSkillPlanRequestSchema.parse(value ?? {})) ) - handleMainWindowSkillIpc( - 'skills:startUpdateRun', - async (_event, names: string[]): Promise => { - return runner.start(Array.isArray(names) ? names : []) - } + 'skills:bundledExecute', + async (_event, value: unknown): Promise => + runtime.executeLocalBundledSkills(BundledSkillExecuteRequestSchema.parse(value)) ) - - handleMainWindowSkillIpc('skills:cancelUpdateRun', async (): Promise => { - runner.cancel() - }) - - handleMainWindowSkillIpc('skills:acknowledgeUpdateRun', async (): Promise => { - runner.acknowledge() - }) - - handleMainWindowSkillIpc('skills:getUpdateRun', async (): Promise => { - return runner.getState() - }) } diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index fc5a27f52f0..7534a46cd5a 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -34,15 +34,14 @@ import { detectRemoteWindowsTerminalCapabilities, type RemoteWindowsTerminalCapabilities } from '../ipc/preflight-remote-windows-terminal-capabilities' -import { - getTuiAgentDetectionProbeCommands, - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - resolveDetectedTuiAgentIds -} from '../../shared/tui-agent-detection-commands' import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { prunePreflightWslCache } from '../preflight-wsl-cache' -import { detectAgentCommandsOnHost } from './agent-command-detection' +import { detectInstalledAgents } from './installed-agent-detection' export { detectAgentCommandsOnHost } from './agent-command-detection' +export { + detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration +} from './installed-agent-detection' export type PreflightStatus = { git: { installed: boolean } @@ -139,25 +138,6 @@ async function detectCommandRuntime( : { installed: false } } -export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const commands = getTuiAgentDetectionProbeCommands( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - getPreflightWslTarget(context) ? 'wsl' : process.platform - ) - return resolveDetectedTuiAgentIds( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - await detectAgentCommandsOnHost(commands, { context }), - getPreflightWslTarget(context) ? 'wsl' : process.platform - ) -} - -export async function detectInstalledAgentsWithShellPathHydration( - context?: PreflightRuntimeContext -): Promise { - await hydrateShellPathForAgentDetection(context) - return detectInstalledAgents(context) -} - export type RefreshAgentsResult = { /** Agents detected after hydrating PATH from the user's login shell. */ agents: string[] diff --git a/src/main/preflight/installed-agent-detection.ts b/src/main/preflight/installed-agent-detection.ts new file mode 100644 index 00000000000..32cc0fc43c6 --- /dev/null +++ b/src/main/preflight/installed-agent-detection.ts @@ -0,0 +1,30 @@ +// Why its own module: `orca skills` loads it outside Electron, so it must stay free of +// agent-detection.ts's integration clients. +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from '../../shared/tui-agent-detection-commands' +import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell-path' +import { + getPreflightWslTarget, + type PreflightRuntimeContext +} from '../ipc/preflight-runtime-target' +import { detectAgentCommandsOnHost } from './agent-command-detection' + +export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { + const runtime = getPreflightWslTarget(context) ? 'wsl' : process.platform + const commands = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime) + return resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + await detectAgentCommandsOnHost(commands, { context }), + runtime + ) +} + +export async function detectInstalledAgentsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgents(context) +} diff --git a/src/main/runtime/runtime-bundled-skill-install.test.ts b/src/main/runtime/runtime-bundled-skill-install.test.ts new file mode 100644 index 00000000000..f46e8e8f04f --- /dev/null +++ b/src/main/runtime/runtime-bundled-skill-install.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { NAME, OLD_TEXT, NEW_TEXT } = vi.hoisted(() => ({ + NAME: 'test-skill', + OLD_TEXT: '---\nname: test-skill\ndescription: Old\n---\n\nOld body\n', + NEW_TEXT: '---\nname: test-skill\ndescription: New\n---\n\nNew body\n' +})) + +vi.mock('../skills/bundled-skill-install/bundled-skill-sources', async () => { + const { createHash } = await import('node:crypto') + const sha = (text: string) => createHash('sha256').update(text).digest('hex') + return { + BUNDLED_SKILL_SOURCES: [ + { name: NAME, text: NEW_TEXT, releasedDigests: [sha(OLD_TEXT), sha(NEW_TEXT)] } + ] + } +}) +vi.mock('../preflight/installed-agent-detection', () => ({ + detectInstalledAgentsWithShellPathHydration: async () => ['claude', 'codex', 'cursor'] +})) + +import { localBundledSkillInput } from '../skills/bundled-skill-install/local-host' +import { previewBundledSkillInstall } from '../skills/bundled-skill-install/service' +import { + readUserClaudeConfigDir, + resolveClaudeDefaultHome +} from '../claude-accounts/claude-profile-paths' +import type { RuntimeSkillCommandHost } from './runtime-skill-command-contract' +import { planLocalBundledSkills } from './runtime-bundled-skill-install' + +const roots: string[] = [] + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function homeWithClaudeCopy(claudeDir: (home: string) => string) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-bundled-app-cli-'))) + roots.push(root) + const home = join(root, 'home') + const copy = join(claudeDir(home), 'skills', NAME) + await mkdir(copy, { recursive: true }) + await writeFile(join(copy, 'SKILL.md'), OLD_TEXT) + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) + return { home, userData: join(root, 'user-data') } +} + +function appHost(home: string, userData: string): RuntimeSkillCommandHost { + const host = { + getRuntimeId: () => 'runtime-1', + getUserDataPath: () => userData, + skillTransactionRecovery: Promise.resolve(), + // Why: the account router's host answer for System default. + getClaudeConfigDirectory: () => + resolveClaudeDefaultHome(home, readUserClaudeConfigDir(process.env)) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: global-scope planning reads only these host members. + return host as unknown as RuntimeSkillCommandHost +} + +describe('app and CLI plan the same targets for one machine', () => { + it.each([ + ['default ~/.claude', (home: string) => join(home, '.claude'), undefined], + ['CLAUDE_CONFIG_DIR', (home: string) => join(home, 'claude-config'), 'claude-config'] + ])('%s', async (_label, claudeDir, configDirName) => { + const { home, userData } = await homeWithClaudeCopy(claudeDir) + if (configDirName) { + vi.stubEnv('CLAUDE_CONFIG_DIR', join(home, configDirName)) + } + const app = await planLocalBundledSkills(appHost(home, userData), {}) + const cli = await previewBundledSkillInstall( + await localBundledSkillInput({ + userDataPath: userData, + destination: { scope: 'global', homeDirectory: home }, + selection: { installedOnly: true } + }) + ) + expect(app.skills[0].ops.map((op) => [op.path, op.kind])).toEqual([ + [join(claudeDir(home), 'skills', NAME), 'update'] + ]) + expect(cli).toEqual(app) + }) +}) diff --git a/src/main/runtime/runtime-bundled-skill-install.ts b/src/main/runtime/runtime-bundled-skill-install.ts new file mode 100644 index 00000000000..63af09d96f4 --- /dev/null +++ b/src/main/runtime/runtime-bundled-skill-install.ts @@ -0,0 +1,53 @@ +import type { + BundledSkillExecuteRequest, + BundledSkillInstallResult, + BundledSkillPlan, + BundledSkillPlanRequest +} from '../../shared/bundled-skill-plan' +import { localBundledSkillInput } from '../skills/bundled-skill-install/local-host' +import { + installBundledSkills, + previewBundledSkillInstall, + type BundledSkillServiceInput +} from '../skills/bundled-skill-install/service' +import { clearSkillDiscoveryCaches } from '../skills/skill-discovery-target' +import { resolveSkillInstallDestination } from '../skills/skill-install-destinations' +import type { RuntimeSkillCommandHost } from './runtime-skill-command-contract' +import { createSkillInstallAuthority } from './runtime-skill-install-authority' + +// Why global and installed-only: this is the app's Update skills, which never adds a skill +// the user did not install. WSL and remote hosts plan on their own host (not here). +async function appInput( + host: RuntimeSkillCommandHost, + force: readonly string[] | undefined +): Promise { + const destination = await resolveSkillInstallDestination( + { scope: 'global' }, + createSkillInstallAuthority(host) + ) + // Why: a cloud transaction this process is still recovering holds the same paths. + await host.skillTransactionRecovery + return localBundledSkillInput({ + userDataPath: host.getUserDataPath(), + destination, + claudeConfigDirectory: host.getClaudeConfigDirectory?.({ runtime: 'host' }), + selection: { installedOnly: true, ...(force ? { force } : {}) } + }) +} + +export async function planLocalBundledSkills( + host: RuntimeSkillCommandHost, + request: BundledSkillPlanRequest +): Promise { + return previewBundledSkillInstall(await appInput(host, request.force)) +} + +export async function executeLocalBundledSkills( + host: RuntimeSkillCommandHost, + request: BundledSkillExecuteRequest +): Promise { + const result = await installBundledSkills(await appInput(host, request.force), request.planDigest) + // Discovery would otherwise serve its pre-install answer from cache. + clearSkillDiscoveryCaches() + return result +} diff --git a/src/main/runtime/runtime-skill-command-contract.ts b/src/main/runtime/runtime-skill-command-contract.ts index 063e2c82dbd..6c396096ff8 100644 --- a/src/main/runtime/runtime-skill-command-contract.ts +++ b/src/main/runtime/runtime-skill-command-contract.ts @@ -36,6 +36,12 @@ import type { SkillRemoveRequest } from '../../shared/skill-install-contract' import type { SkillProviderRootOverrides } from '../skills/skill-provider-destinations' +import type { + BundledSkillExecuteRequest, + BundledSkillInstallResult, + BundledSkillPlan, + BundledSkillPlanRequest +} from '../../shared/bundled-skill-plan' export type RuntimeSkillCommandSurface = { setSkillCloudService(service: SkillCloudService): void assertAgentSkillSharingAllowed(): void @@ -98,6 +104,8 @@ export type RuntimeSkillCommandSurface = { signal?: AbortSignal, onProgress?: (progress: SkillBundleInstallProgress) => void ): Promise + planLocalBundledSkills(request: BundledSkillPlanRequest): Promise + executeLocalBundledSkills(request: BundledSkillExecuteRequest): Promise getSharedSkillInstallProgress(operationId: string): SkillBundleInstallProgress | null cancelSharedSkillInstall(operationId: string): boolean previewSharedSkillInstallRequest( diff --git a/src/main/runtime/runtime-skill-install-commands.ts b/src/main/runtime/runtime-skill-install-commands.ts index e09ade4a292..f6b9169b881 100644 --- a/src/main/runtime/runtime-skill-install-commands.ts +++ b/src/main/runtime/runtime-skill-install-commands.ts @@ -11,6 +11,13 @@ import type { } from '../../shared/skill-bundle-install-contract' import type { SkillInstallRequest, SkillInstallResult } from '../../shared/skill-install-contract' import type { RuntimeSkillCommandHost } from './runtime-skill-command-contract' +import type { + BundledSkillExecuteRequest, + BundledSkillInstallResult, + BundledSkillPlan, + BundledSkillPlanRequest +} from '../../shared/bundled-skill-plan' +import { executeLocalBundledSkills, planLocalBundledSkills } from './runtime-bundled-skill-install' import { createSkillInstallAuthority, folderExecutionHostId, @@ -164,6 +171,14 @@ export class RuntimeSkillInstallCommands { this.progress.delete(request.operationId) } } + planLocalBundledSkills(request: BundledSkillPlanRequest): Promise { + return planLocalBundledSkills(this.host, request) + } + executeLocalBundledSkills( + request: BundledSkillExecuteRequest + ): Promise { + return executeLocalBundledSkills(this.host, request) + } getSharedSkillInstallProgress(operationId: string) { return this.progress.get(operationId) ?? null } diff --git a/src/main/skills/bundled-skill-install/cli-entry.ts b/src/main/skills/bundled-skill-install/cli-entry.ts new file mode 100644 index 00000000000..0cb84542024 --- /dev/null +++ b/src/main/skills/bundled-skill-install/cli-entry.ts @@ -0,0 +1,3 @@ +// Why one entry: electron-vite replaces out/main, so the CLI can only load modules built as entries. +export { localBundledSkillInput } from './local-host' +export { installBundledSkills, previewBundledSkillInstall } from './service' diff --git a/src/main/skills/bundled-skill-install/cloud-state.ts b/src/main/skills/bundled-skill-install/cloud-state.ts new file mode 100644 index 00000000000..930d250cf76 --- /dev/null +++ b/src/main/skills/bundled-skill-install/cloud-state.ts @@ -0,0 +1,40 @@ +import { lstat } from 'node:fs/promises' +import { resolve } from 'node:path' +import { skillDeleteJournalPath } from '../skill-delete/recovery' +import { skillInstallReceiptPath } from '../skill-install-provenance' +import { skillInstallJournalPath } from '../skill-install-recovery' +import { skillPlacementJournalPath } from '../skill-placement-recovery-journal' +import { skillRemovalJournalPath } from '../skill-remove-recovery' + +// What cloud skill sharing left at a path: a pending transaction, or a receipt for its package. +async function anyFileExists(paths: readonly string[]): Promise { + const found = await Promise.all( + paths.map((path) => + lstat(path).then( + () => true, + () => false + ) + ) + ) + return found.includes(true) +} + +/** Cloud installs key state on the realpath'd destination, so check both spellings. */ +export function stateKeysFor(path: string, realPath: string): string[] { + return [...new Set([resolve(path), realPath])] +} + +export function hasPendingCloudTransaction(stateDirectory: string, keys: readonly string[]) { + return anyFileExists( + keys.flatMap((key) => [ + skillInstallJournalPath(stateDirectory, key), + skillPlacementJournalPath(stateDirectory, key), + skillRemovalJournalPath(stateDirectory, key), + skillDeleteJournalPath(stateDirectory, key) + ]) + ) +} + +export function hasCloudReceipt(stateDirectory: string, keys: readonly string[]) { + return anyFileExists(keys.map((key) => skillInstallReceiptPath(stateDirectory, key))) +} diff --git a/src/main/skills/bundled-skill-install/layouts.test.ts b/src/main/skills/bundled-skill-install/layouts.test.ts index fa0d8d710f9..9a7607686ed 100644 --- a/src/main/skills/bundled-skill-install/layouts.test.ts +++ b/src/main/skills/bundled-skill-install/layouts.test.ts @@ -56,6 +56,8 @@ type Layout = { badge?: 'update' | null agents?: SkillInstallProviderId[] force?: Label[] + /** Update rather than install: only locations that already hold the skill. */ + installedOnly?: boolean filesystem?: BundledSkillFilesystem /** Paths outside every target that the run must leave byte-identical. */ untouched?: (home: SkillHome) => string[] @@ -101,6 +103,24 @@ const LAYOUTS: Layout[] = [ text: 'Updated', badge: 'update' }, + { + name: 'update: npx Claude-only install, four other agents detected, spreads to none', + arrange: async (home) => { + await writeNpxLock(home) + await writeCopy(home.claude, OLD_TEXT) + }, + agents: ['claude', 'codex', 'cursor', 'gemini', 'grok'], + installedOnly: true, + ops: [['claude', 'update']], + text: 'Updated', + badge: 'update', + untouched: (home) => [ + home.shared, + home.cursor, + join(home.home, '.gemini', 'skills'), + join(home.home, '.grok', 'skills') + ] + }, { name: 'already current', arrange: async (home) => { @@ -121,7 +141,7 @@ const LAYOUTS: Layout[] = [ ['shared', 'leave', 'edited'], ['claude', 'leave', 'shared-not-current'] ], - text: "No change · 2 left alone: You've changed this copy; The shared copy this folder uses is not Orca’s", + text: "No change · 1 skipped: Skill edited outside Orca; The shared copy isn't Orca's", badge: null }, { @@ -138,7 +158,7 @@ const LAYOUTS: Layout[] = [ ['claude', 'keep'], ['cursor', 'leave', 'other'] ], - text: 'Updated · 1 left alone: A different skill already uses this name', + text: 'Updated · 1 skipped: A different skill uses this name', untouched: (home) => [join(home.root, 'elsewhere', NAME)] }, { @@ -153,7 +173,7 @@ const LAYOUTS: Layout[] = [ ['shared', 'update'], ['claude', 'leave', 'other'] ], - text: 'Updated · 1 left alone: A different skill already uses this name' + text: 'Updated · 1 skipped: A different skill uses this name' }, { name: 'plugin-managed copy only', @@ -178,7 +198,7 @@ const LAYOUTS: Layout[] = [ ['shared', 'leave', 'unwritable'], ['claude', 'leave', 'shared-not-current'] ], - text: "No change · 2 left alone: Orca can't write to this folder; The shared copy this folder uses is not Orca’s" + text: "No change · 1 skipped: Orca can't write to this folder; The shared copy isn't Orca's" }, { name: 'npx shared copy with Claude and Cursor detected', @@ -213,7 +233,7 @@ const LAYOUTS: Layout[] = [ ['shared', 'leave', 'other'], ['claude', 'leave', 'shared-not-current'] ], - text: 'No change · 2 left alone: A different skill already uses this name; The shared copy this folder uses is not Orca’s' + text: "No change · 1 skipped: A different skill uses this name; The shared copy isn't Orca's" }, { name: 'host that cannot link gets a copy', @@ -250,7 +270,7 @@ const LAYOUTS: Layout[] = [ ['shared', 'leave', 'edited'], ['claude', 'leave', 'shared-not-current'] ], - text: "No change · 2 left alone: You've changed this copy; The shared copy this folder uses is not Orca’s" + text: "No change · 1 skipped: Skill edited outside Orca; The shared copy isn't Orca's" }, { name: '~/.claude/skills is a link to ~/.agents/skills (one target)', @@ -307,6 +327,7 @@ describe('bundled skill install across real layouts', () => { const input = serviceInput(home, { agents: layout.agents ?? ['claude'], ...(layout.force ? { force: layout.force.map((label) => home[label]) } : {}), + ...(layout.installedOnly ? { installedOnly: true } : {}), ...(layout.filesystem ? { filesystem: layout.filesystem } : {}) }) const untouched = layout.untouched?.(home) ?? [] diff --git a/src/main/skills/bundled-skill-install/local-host.test.ts b/src/main/skills/bundled-skill-install/local-host.test.ts new file mode 100644 index 00000000000..fc6c4aa8448 --- /dev/null +++ b/src/main/skills/bundled-skill-install/local-host.test.ts @@ -0,0 +1,95 @@ +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { detectMock } = vi.hoisted(() => ({ detectMock: vi.fn() })) +vi.mock('../../preflight/installed-agent-detection', () => ({ + detectInstalledAgentsWithShellPathHydration: detectMock +})) + +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../../shared/claude-profile-routing' +import { localBundledSkillInput } from './local-host' + +const HOME = resolve('/', 'home', 'u') +const USER_DATA = resolve('/', 'data', 'orca') + +afterEach(() => { + detectMock.mockReset() + vi.unstubAllEnvs() +}) + +describe('localBundledSkillInput', () => { + it('uses explicit agents without detecting, and the cloud-install state directory', async () => { + const input = await localBundledSkillInput({ + userDataPath: USER_DATA, + destination: { scope: 'global', homeDirectory: HOME }, + agents: [], + claudeConfigDirectory: join(HOME, '.claude'), + selection: { names: ['orca-cli'], force: 'all', installedOnly: true } + }) + expect(detectMock).not.toHaveBeenCalled() + expect(input).toMatchObject({ + destination: { scope: 'global', homeDirectory: HOME }, + agents: [], + providerRootOverrides: { claude: join(HOME, '.claude', 'skills') }, + stateDirectory: join(USER_DATA, 'skill-installs'), + names: ['orca-cli'], + force: 'all', + installedOnly: true + }) + }) + + it('detects agents when none are given and keeps only ones Orca can place a skill for', async () => { + detectMock.mockResolvedValue(['claude', 'aider', 'codex']) + const input = await localBundledSkillInput({ + userDataPath: USER_DATA, + destination: { scope: 'global', homeDirectory: HOME }, + selection: {} + }) + expect(input.agents).toEqual(['claude', 'codex']) + expect(input).not.toHaveProperty('force') + expect(input).not.toHaveProperty('installedOnly') + }) + + it('reads provider homes from the environment for global scope only', async () => { + const claudeHome = join(HOME, 'custom-claude') + vi.stubEnv('CLAUDE_CONFIG_DIR', claudeHome) + const global = await localBundledSkillInput({ + userDataPath: USER_DATA, + destination: { scope: 'global', homeDirectory: HOME }, + agents: ['claude'], + selection: {} + }) + expect(global.providerRootOverrides).toMatchObject({ claude: join(claudeHome, 'skills') }) + + const workspace = await localBundledSkillInput({ + userDataPath: USER_DATA, + destination: { + scope: 'workspace', + homeDirectory: HOME, + workspaceDirectory: join(HOME, 'repo') + }, + agents: ['claude'], + selection: {} + }) + expect(workspace.providerRootOverrides).toEqual({}) + }) + + it("reads past a CLAUDE_CONFIG_DIR Orca injected, as the app's account router does", async () => { + const injected = join(USER_DATA, 'claude-profiles', 'work') + vi.stubEnv('CLAUDE_CONFIG_DIR', injected) + vi.stubEnv(CLAUDE_INJECTED_CONFIG_DIR_ENV, injected) + vi.stubEnv(CLAUDE_USER_CONFIG_DIR_ENV, '') + const input = await localBundledSkillInput({ + userDataPath: USER_DATA, + destination: { scope: 'global', homeDirectory: HOME }, + agents: ['claude'], + selection: {} + }) + expect(input.providerRootOverrides).toMatchObject({ + claude: join(HOME, '.claude', 'skills') + }) + }) +}) diff --git a/src/main/skills/bundled-skill-install/local-host.ts b/src/main/skills/bundled-skill-install/local-host.ts new file mode 100644 index 00000000000..ba847b531d6 --- /dev/null +++ b/src/main/skills/bundled-skill-install/local-host.ts @@ -0,0 +1,64 @@ +import { join } from 'node:path' +import { + isSkillInstallProviderId, + type SkillInstallProviderId +} from '../../../shared/skill-install-providers' +import { detectInstalledAgentsWithShellPathHydration } from '../../preflight/installed-agent-detection' +import { + readUserClaudeConfigDir, + resolveClaudeDefaultHome +} from '../../claude-accounts/claude-profile-paths' +import { + resolveEnvironmentSkillProviderRoots, + withClaudeSkillProviderRoot +} from '../skill-provider-runtime-roots' +import type { BundledSkillServiceInput } from './service' +import type { BundledSkillTargetRequest } from './targets' + +/** What the caller chose; everything else about the host is resolved here. */ +export type BundledSkillSelection = { + names?: readonly string[] + force?: readonly string[] | 'all' + installedOnly?: boolean +} + +/** + * The one input builder for this machine, shared by the app and `orca skills`, so both plan + * the same targets: detected agents, provider-root overrides and the cloud-install state dir. + */ +export async function localBundledSkillInput(options: { + userDataPath: string + destination: BundledSkillTargetRequest['destination'] + /** `--agent`; detected on this host when omitted. */ + agents?: readonly SkillInstallProviderId[] + /** + * The app passes its account router's answer; otherwise the same rule reads this process's + * env, ignoring a CLAUDE_CONFIG_DIR Orca injected into a managed-account terminal. + */ + claudeConfigDirectory?: string | null + selection: BundledSkillSelection +}): Promise { + const { destination, selection } = options + const agents = + options.agents ?? + (await detectInstalledAgentsWithShellPathHydration()).filter(isSkillInstallProviderId) + return { + destination, + agents, + providerRootOverrides: + destination.scope === 'global' + ? withClaudeSkillProviderRoot( + resolveEnvironmentSkillProviderRoots(process.env, destination.homeDirectory), + options.claudeConfigDirectory ?? + resolveClaudeDefaultHome( + destination.homeDirectory, + readUserClaudeConfigDir(process.env) + ) + ) + : {}, + stateDirectory: join(options.userDataPath, 'skill-installs'), + ...(selection.names ? { names: selection.names } : {}), + ...(selection.force ? { force: selection.force } : {}), + ...(selection.installedOnly ? { installedOnly: true } : {}) + } +} diff --git a/src/main/skills/bundled-skill-install/plan-selection.test.ts b/src/main/skills/bundled-skill-install/plan-selection.test.ts new file mode 100644 index 00000000000..caee0f3919e --- /dev/null +++ b/src/main/skills/bundled-skill-install/plan-selection.test.ts @@ -0,0 +1,171 @@ +import { mkdir, readFile, readdir } from 'node:fs/promises' +import { dirname, join, relative } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { previewBundledSkillInstall } from './service' +import { + linkDirectory, + makeSkillHome, + NAME, + NEW_TEXT, + OLD_TEXT, + opsOf, + previewAndInstall, + removeSkillHomes, + serviceInput, + snapshot, + writeCopy +} from './skill-home.test-support' + +const EDITED_TEXT = `---\nname: ${NAME}\n---\nmine\n` +const OTHER_TEXT = `---\nname: someone-elses-skill\n---\ntheirs\n` + +afterEach(removeSkillHomes) + +describe('installedOnly', () => { + it('drops a skill with no copy anywhere, so update never installs it', async () => { + const home = await makeSkillHome() + const plan = await previewBundledSkillInstall({ + ...serviceInput(home), + installedOnly: true + }) + expect(plan.skills).toEqual([]) + // Install (no flag) would write and link it. + expect(opsOf(await previewBundledSkillInstall(serviceInput(home)))).toEqual([ + [home.shared, 'write', undefined], + [home.claude, 'link', undefined] + ]) + }) + + it('keeps a skill whose only copy is one the user edited', async () => { + const home = await makeSkillHome() + await writeCopy(home.claude, EDITED_TEXT) + const plan = await previewBundledSkillInstall({ + ...serviceInput(home), + installedOnly: true + }) + expect(opsOf(plan)).toEqual([[home.claude, 'leave', 'edited']]) + }) + + it("drops a skill whose only copy is someone else's skill with the same name", async () => { + const home = await makeSkillHome() + await writeCopy(home.claude, OTHER_TEXT) + const plan = await previewBundledSkillInstall({ + ...serviceInput(home), + installedOnly: true + }) + expect(plan.skills).toEqual([]) + }) + + it('keeps a released copy and executes the same plan it previewed', async () => { + const home = await makeSkillHome() + await writeCopy(home.shared, OLD_TEXT) + const input = { ...serviceInput(home), installedOnly: true } + const { plan } = await previewAndInstall(input) + expect(opsOf(plan)).toEqual([[home.shared, 'update', undefined]]) + expect(await snapshot(home.shared)).toBe(`dir[SKILL.md]:${NEW_TEXT}`) + }) + + it('replaces an edited Claude-only copy in place, without adding a shared copy', async () => { + const home = await makeSkillHome() + await writeCopy(home.claude, EDITED_TEXT) + const input = { ...serviceInput(home, { force: [home.claude] }), installedOnly: true } + const { plan } = await previewAndInstall(input) + expect(opsOf(plan)).toEqual([[home.claude, 'replace', undefined]]) + expect(plan.skills[0].ops[0].content).toBe('files') + expect(await snapshot(home.claude)).toBe(`dir[SKILL.md]:${NEW_TEXT}`) + expect(await snapshot(home.shared)).toBe('missing') + }) + + it("writes the shared copy when an agent's existing link points at it", async () => { + const home = await makeSkillHome() + await writeCopy(home.cursor, OLD_TEXT) + await mkdir(dirname(home.claude), { recursive: true }) + await linkDirectory( + process.platform === 'win32' ? home.shared : relative(dirname(home.claude), home.shared), + home.claude + ) + const input = { ...serviceInput(home, { agents: ['claude', 'cursor'] }), installedOnly: true } + const { plan } = await previewAndInstall(input) + expect(opsOf(plan)).toEqual([ + [home.shared, 'write', undefined], + [home.claude, 'keep', undefined], + [home.cursor, 'update', undefined] + ]) + expect(await readFile(join(home.claude, 'SKILL.md'), 'utf8')).toBe(NEW_TEXT) + }) +}) + +describe("force: 'all'", () => { + it('replaces every replaceable copy left alone and keeps a backup of each', async () => { + const home = await makeSkillHome() + await writeCopy(home.shared, EDITED_TEXT) + await writeCopy(home.cursor, OTHER_TEXT) + const input = serviceInput(home, { agents: ['cursor'] }) + expect(opsOf(await previewBundledSkillInstall(input))).toEqual([ + [home.shared, 'leave', 'edited'], + [home.cursor, 'leave', 'other'] + ]) + + const { plan } = await previewAndInstall({ ...input, force: 'all' }) + expect(opsOf(plan)).toEqual([ + [home.shared, 'replace', undefined], + [home.cursor, 'replace', undefined] + ]) + expect(await snapshot(home.shared)).toBe(`dir[SKILL.md]:${NEW_TEXT}`) + const backups = join(home.home, '.agents', '.orca-skill-backups') + const kept = await Promise.all( + (await readdir(backups)).map((entry) => readFile(join(backups, entry, 'SKILL.md'), 'utf8')) + ) + expect(kept.sort()).toEqual([EDITED_TEXT, OTHER_TEXT].sort()) + }) +}) + +describe('an escaping workspace shared root', () => { + it.each([ + { installedOnly: false, text: null, force: undefined, kinds: ['write', 'write'] }, + { installedOnly: true, text: null, force: undefined, kinds: [] }, + { installedOnly: true, text: OLD_TEXT, force: undefined, kinds: ['update'] }, + { installedOnly: true, text: EDITED_TEXT, force: undefined, kinds: ['leave'] }, + { installedOnly: true, text: EDITED_TEXT, force: 'all' as const, kinds: ['replace'] } + ])( + 'plans agents without shared content: $installedOnly, $force, $kinds', + async ({ installedOnly, text, force, kinds }) => { + const home = await makeSkillHome() + const project = join(home.root, 'project') + const claude = join(project, '.claude', 'skills', NAME) + const openclaude = join(project, '.openclaude', 'skills', NAME) + await writeCopy(home.shared, OLD_TEXT) + await mkdir(join(project, '.agents'), { recursive: true }) + await mkdir(dirname(claude), { recursive: true }) + await mkdir(dirname(openclaude), { recursive: true }) + await linkDirectory(dirname(home.shared), join(project, '.agents', 'skills')) + if (text) { + await writeCopy(claude, text) + } + + const { plan } = await previewAndInstall({ + ...serviceInput(home, { workspaceDirectory: project, agents: ['claude', 'openclaude'] }), + installedOnly, + force + }) + + const ops = plan.skills.flatMap((skill) => skill.ops) + expect(ops.map((op) => op.kind)).toEqual(kinds) + expect(ops.map((op) => op.path)).toEqual( + kinds.length === 2 ? [claude, openclaude] : kinds.length === 1 ? [claude] : [] + ) + for (const op of ops) { + expect(op.role).toBe('agent') + expect(op.linkTarget).toBeUndefined() + expect(op.requires).toBeUndefined() + if (op.kind !== 'leave') { + expect(op.content).toBe('files') + } + } + expect(await snapshot(home.shared)).toBe(`dir[SKILL.md]:${OLD_TEXT}`) + if (installedOnly) { + expect(await snapshot(openclaude)).toBe('missing') + } + } + ) +}) diff --git a/src/main/skills/bundled-skill-install/plan.ts b/src/main/skills/bundled-skill-install/plan.ts index 72d6a61754e..8a7aeab94c9 100644 --- a/src/main/skills/bundled-skill-install/plan.ts +++ b/src/main/skills/bundled-skill-install/plan.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto' -import { lstat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import type { BundledSkillCleanup, @@ -8,12 +7,8 @@ import type { BundledSkillPlan, BundledSkillPlanEntry } from '../../../shared/bundled-skill-plan' -import { skillDeleteJournalPath } from '../skill-delete/recovery' -import { skillInstallReceiptPath } from '../skill-install-provenance' -import { skillInstallJournalPath } from '../skill-install-recovery' -import { skillPlacementJournalPath } from '../skill-placement-recovery-journal' -import { skillRemovalJournalPath } from '../skill-remove-recovery' import type { BundledSkillSource } from './bundled-skill-sources' +import { hasCloudReceipt, hasPendingCloudTransaction, stateKeysFor } from './cloud-state' import { bundledSkillTextDigest, classifyBundledSkillPath, @@ -32,8 +27,13 @@ import { export type BundledSkillPlanInput = BundledSkillTargetRequest & { /** Bundled skill names; every bundled skill when omitted. */ names?: readonly string[] - /** Target paths the user chose to Replace (`--force`). */ - force?: readonly string[] + /** Target paths the user chose to Replace; `all` is `--force`, every replaceable one. */ + force?: readonly string[] | 'all' + /** + * Update: plan only locations that already hold something, so an update never spreads a + * skill to another agent; a skill with no copy of Orca's anywhere is skipped. + */ + installedOnly?: boolean /** `/skill-installs`, where cloud installs keep receipts and journals. */ stateDirectory: string filesystem: BundledSkillFilesystem @@ -49,7 +49,7 @@ type SkillContext = { input: BundledSkillPlanInput source: BundledSkillSource digest: string - force: Set + force: Set | 'all' } type SharedTarget = { path: string; realPath: string; current: boolean } @@ -73,38 +73,6 @@ export function sameBundledSkillObserved( return JSON.stringify(left) === JSON.stringify(right) } -async function anyFileExists(paths: readonly string[]): Promise { - const found = await Promise.all( - paths.map((path) => - lstat(path).then( - () => true, - () => false - ) - ) - ) - return found.includes(true) -} - -/** Cloud installs key state on the realpath'd destination, so check both spellings. */ -function stateKeysFor(path: string, realPath: string): string[] { - return [...new Set([resolve(path), realPath])] -} - -function hasPendingCloudTransaction(stateDirectory: string, keys: readonly string[]) { - return anyFileExists( - keys.flatMap((key) => [ - skillInstallJournalPath(stateDirectory, key), - skillPlacementJournalPath(stateDirectory, key), - skillRemovalJournalPath(stateDirectory, key), - skillDeleteJournalPath(stateDirectory, key) - ]) - ) -} - -function hasCloudReceipt(stateDirectory: string, keys: readonly string[]) { - return anyFileExists(keys.map((key) => skillInstallReceiptPath(stateDirectory, key))) -} - async function linkPointsAt( filesystem: BundledSkillFilesystem, path: string, @@ -159,11 +127,15 @@ async function recoverySiblings( } } +type PlannedTarget = { op: BundledSkillOp; holdsOrcaCopy: boolean } + +const ORCA_COPY_CLASSES = new Set(['current', 'released', 'edited']) + async function planTarget( context: SkillContext, root: BundledSkillTargetRoot, shared: SharedTarget | null -): Promise { +): Promise { const { filesystem, stateDirectory } = context.input const path = join(root.path, context.source.name) const realPath = join(root.realPath, context.source.name) @@ -173,7 +145,7 @@ async function planTarget( path, context.source, context.digest, - context.force.has(bundledSkillPathKey(path)) + context.force === 'all' || context.force.has(bundledSkillPathKey(path)) ) const { observed } = classified let folderClass: BundledSkillFolderClass = classified.folderClass @@ -238,7 +210,7 @@ async function planTarget( ? { ...base, kind: 'keep', cleanup } : leave('shared-not-current', false) } - if (!context.force.has(bundledSkillPathKey(path))) { + if (context.force !== 'all' && !context.force.has(bundledSkillPathKey(path))) { return leave(folderClass === 'edited' ? 'edited' : 'other', true) } return shared?.current && filesystem.supportsLinks @@ -246,10 +218,11 @@ async function planTarget( : files('replace') } + const holdsOrcaCopy = Boolean(restoreFrom) || ORCA_COPY_CLASSES.has(folderClass) const op = await decide() const writes = op.kind !== 'keep' && op.kind !== 'leave' if (!writes && op.cleanup.length === 0) { - return op + return { op, holdsOrcaCopy } } const blocked = (await hasPendingCloudTransaction(stateDirectory, stateKeys)) ? 'busy' @@ -257,38 +230,73 @@ async function planTarget( ? null : 'unwritable' if (!blocked) { - return op + return { op, holdsOrcaCopy } } - return { ...(writes ? leave(blocked, false) : op), cleanup: [] } + return { op: { ...(writes ? leave(blocked, false) : op), cleanup: [] }, holdsOrcaCopy } } async function planSkill( input: BundledSkillPlanInput, roots: readonly BundledSkillTargetRoot[], source: BundledSkillSource -): Promise { +): Promise { const context: SkillContext = { input, source, digest: bundledSkillTextDigest(Buffer.from(source.text, 'utf8')), - force: new Set((input.force ?? []).map(bundledSkillPathKey)) + force: input.force === 'all' ? 'all' : new Set((input.force ?? []).map(bundledSkillPathKey)) } - let shared: SharedTarget | null = null - const ops: BundledSkillOp[] = [] - for (const root of roots) { - const op = await planTarget(context, root, shared) - ops.push(op) - if (root.role === 'shared') { - shared = { - path: op.path, - realPath: join(root.realPath, source.name), - current: WRITES_SHARED_CURRENT.has(op.kind) - } + const sharedRoot = roots.find((root) => root.role === 'shared') + const agentRoots = roots.filter((root) => root.role === 'agent') + const sharedTarget = sharedRoot ? await planTarget(context, sharedRoot, null) : null + const shared: SharedTarget | null = + sharedRoot && sharedTarget + ? { + path: sharedTarget.op.path, + realPath: join(sharedRoot.realPath, source.name), + current: WRITES_SHARED_CURRENT.has(sharedTarget.op.kind) + } + : null + const planAgents = async (target: SharedTarget | null) => { + const agents: PlannedTarget[] = [] + for (const root of agentRoots) { + agents.push(await planTarget(context, root, target)) + } + return agents + } + let agents = await planAgents(shared) + const targets = [...(sharedTarget ? [sharedTarget] : []), ...agents] + if (!input.installedOnly) { + return { + name: source.name, + digest: context.digest, + ops: targets.map((target) => target.op) } } + if (!targets.some((target) => target.holdsOrcaCopy)) { + return null + } + // Why: an empty shared slot is written only to repair an agent's existing link to it; any + // other plan (a Replace among them) must not add the skill there, so it plans without it. + const keepShared = + sharedTarget && + (isPresent(sharedTarget.op) || + agents.some((target) => target.op.kind === 'keep' && target.op.observed.kind === 'link')) + if (!keepShared && shared?.current) { + agents = await planAgents({ ...shared, current: false }) + } + const ops = [ + ...(keepShared ? [sharedTarget.op] : []), + ...agents.filter((target) => isPresent(target.op)).map((target) => target.op) + ] return { name: source.name, digest: context.digest, ops } } +/** Something is at the path, or an interrupted swap left a sibling to settle there. */ +function isPresent(op: BundledSkillOp): boolean { + return op.observed.kind !== 'absent' || op.restoreFrom !== undefined || op.cleanup.length > 0 +} + /** * The only code that decides what a bundled-skill install does. Preview and execute both come * through here, and execute applies a plan only while it still hashes to what was previewed. @@ -308,10 +316,18 @@ export async function buildBundledSkillPlan( : input.sources const skills: BundledSkillPlanEntry[] = [] for (const source of selected) { - skills.push(await planSkill(input, roots, source)) + const entry = await planSkill(input, roots, source) + if (entry) { + skills.push(entry) + } } return { - plan: { scope: input.destination.scope, skills, planDigest: bundledSkillDigestOf(skills) }, + plan: { + scope: input.destination.scope, + homeDirectory: input.destination.homeDirectory, + skills, + planDigest: bundledSkillDigestOf(skills) + }, roots } } diff --git a/src/main/skills/bundled-skill-install/service.ts b/src/main/skills/bundled-skill-install/service.ts index bb8034ea400..8ba6098e7ca 100644 --- a/src/main/skills/bundled-skill-install/service.ts +++ b/src/main/skills/bundled-skill-install/service.ts @@ -2,7 +2,6 @@ import type { BundledSkillInstallResult, BundledSkillPlan } from '../../../shared/bundled-skill-plan' -import { clearSkillDiscoveryCaches } from '../skill-discovery-target' import { BUNDLED_SKILL_SOURCES } from './bundled-skill-sources' import { executeBundledSkillPlan, type BundledSkillExecuteInput } from './execute' import { nativeBundledSkillFilesystem } from './filesystem' @@ -30,8 +29,5 @@ export async function installBundledSkills( input: BundledSkillServiceInput, planDigest: string ): Promise { - const result = await executeBundledSkillPlan(withDefaults(input), planDigest) - // Discovery would otherwise serve its pre-install answer from cache. - clearSkillDiscoveryCaches() - return result + return executeBundledSkillPlan(withDefaults(input), planDigest) } diff --git a/src/main/skills/bundled-skill-install/skill-home.test-support.ts b/src/main/skills/bundled-skill-install/skill-home.test-support.ts index 2881310d8a1..cafeb81d83f 100644 --- a/src/main/skills/bundled-skill-install/skill-home.test-support.ts +++ b/src/main/skills/bundled-skill-install/skill-home.test-support.ts @@ -99,6 +99,7 @@ export function serviceInput( options: { agents?: SkillInstallProviderId[] force?: string[] + installedOnly?: boolean filesystem?: BundledSkillFilesystem workspaceDirectory?: string providerRootOverrides?: BundledSkillServiceInput['providerRootOverrides'] @@ -117,6 +118,7 @@ export function serviceInput( sources: [NEW_SOURCE], lockTimeoutMs: 200, ...(options.force ? { force: options.force } : {}), + ...(options.installedOnly ? { installedOnly: true } : {}), ...(options.filesystem ? { filesystem: options.filesystem } : {}), ...(options.providerRootOverrides ? { providerRootOverrides: options.providerRootOverrides } diff --git a/src/main/skills/skill-bundle-artifacts.test.ts b/src/main/skills/skill-bundle-artifacts.test.ts deleted file mode 100644 index 04ded288d9d..00000000000 --- a/src/main/skills/skill-bundle-artifacts.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { loadSkillBundleManifest } from './skill-bundle-artifacts' - -const temporaryDirectories: string[] = [] - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) -}) - -async function resourceRootWith(manifest: string): Promise { - const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) - temporaryDirectories.push(resourceRoot) - await mkdir(join(resourceRoot, 'skills'), { recursive: true }) - await writeFile(join(resourceRoot, 'skills', 'current-manifest.json'), manifest) - return resourceRoot -} - -describe('skill bundle manifest', () => { - it('loads the committed schema-3 manifest and rejects a schema-2 one', async () => { - const manifest = await readFile(resolve('resources', 'skills', 'current-manifest.json'), 'utf8') - - const loaded = await loadSkillBundleManifest(await resourceRootWith(manifest)) - expect(loaded.schemaVersion).toBe(3) - expect(loaded.skills.length).toBeGreaterThan(0) - - // Why: schema 2 carried registry revisions; a stale artifact must fail loudly. - const legacy = JSON.parse(manifest) - legacy.schemaVersion = 2 - for (const skill of legacy.skills) { - skill.releaseRevision = 1 - skill.gitTreeSha = '0'.repeat(40) - } - await expect( - loadSkillBundleManifest(await resourceRootWith(JSON.stringify(legacy))) - ).rejects.toThrow('Invalid skill bundle manifest') - }) -}) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts deleted file mode 100644 index 08defa7541f..00000000000 --- a/src/main/skills/skill-bundle-artifacts.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { app } from 'electron' -import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import { z } from 'zod' -import type { SkillBundleManifest } from '../../shared/skill-freshness' - -const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/) -const manifestSchema = z - .object({ - schemaVersion: z.literal(3), - skills: z.array( - z - .object({ - name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/), - sourcePath: z.string().min(1), - packageDigest: sha256Schema, - files: z - .array( - z - .object({ - path: z.string().min(1), - size: z.number().int().nonnegative(), - executable: z.boolean(), - classification: z.enum(['text', 'binary']), - exactSha256: sha256Schema, - textNormalizedSha256: sha256Schema.nullable(), - identitySha256: sha256Schema - }) - .strict() - ) - .min(1) - }) - .strict() - ) - }) - .strict() - -const manifestsByResourceRoot = new Map>() - -// Why: the manifest ships with the binary and never changes within a run, while -// focus-triggered rescans would otherwise re-read and re-parse it every time. -export function loadSkillBundleManifest( - resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources') -): Promise { - const cached = manifestsByResourceRoot.get(resourceRoot) - if (cached) { - return cached - } - const loading = readSkillBundleManifest(resourceRoot) - manifestsByResourceRoot.set(resourceRoot, loading) - loading.catch(() => { - manifestsByResourceRoot.delete(resourceRoot) - }) - return loading -} - -async function readSkillBundleManifest(resourceRoot: string): Promise { - const value: unknown = JSON.parse( - await readFile(join(resourceRoot, 'skills', 'current-manifest.json'), 'utf8') - ) - const result = manifestSchema.safeParse(value) - if (!result.success) { - throw new Error( - `Invalid skill bundle manifest: ${result.error.issues[0]?.message ?? 'schema mismatch'}` - ) - } - return result.data -} diff --git a/src/main/skills/skill-freshness-eligibility.test.ts b/src/main/skills/skill-freshness-eligibility.test.ts deleted file mode 100644 index faa92a89c9b..00000000000 --- a/src/main/skills/skill-freshness-eligibility.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - canonicalizeSkillUpdateNames, - type SkillFreshnessInstallation -} from '../../shared/skill-freshness' -import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' - -function placement( - name: string, - overrides: Partial = {} -): SkillFreshnessInstallation { - return { - id: `${name}-${overrides.rootId ?? 'home-agents'}`, - name, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: `/home/.agents/skills/${name}`, - resolvedPath: `/home/.agents/skills/${name}`, - physicalIdentity: `physical-${name}`, - topology: 'canonical-copy', - status: 'outdated', - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null, - ...overrides - } -} - -describe('skill freshness name-scoped update eligibility', () => { - it('offers a name when at least one supported placement is outdated', () => { - expect( - eligibleSkillUpdateNames([ - placement('orca-cli'), - placement('orca-cli', { - id: 'orca-cli-claude', - rootId: 'home-claude', - topology: 'provider-alias', - status: 'current' - }) - ]) - ).toEqual(['orca-cli']) - }) - - it.each([null, 'acme/agent-skills'])( - 'offers an outdated copy whatever the lock says installed it (%s)', - (lockSource) => { - // Why: ownership only decides preselection; #21495 came from gating on the lock. - expect(eligibleSkillUpdateNames([placement('orca-cli', { lockSource })])).toEqual([ - 'orca-cli' - ]) - } - ) - - it.each([ - ['outdated', 'independent-copy'], - ['current', 'repo-scope'], - ['outdated', 'repo-scope'], - ['outdated', 'plugin-cache'], - ['inaccessible', 'plugin-cache'] - ] as const)( - 'still updates the canonical copy despite a %s placement in %s topology', - (status, topology) => { - expect( - eligibleSkillUpdateNames([ - placement('orca-cli'), - placement('orca-cli', { id: `outlier-${status}-${topology}`, status, topology }) - ]) - ).toEqual(['orca-cli']) - } - ) - - it.each(['external-link', 'broken-link', 'read-only'] as const)( - 'skips the name when a home placement is a %s the install would write through', - (topology) => { - expect( - eligibleSkillUpdateNames([ - placement('orca-cli'), - placement('orca-cli', { - id: `orca-cli-${topology}`, - rootId: 'home-claude', - unresolvedPath: '/home/.claude/skills/orca-cli', - topology, - status: topology === 'broken-link' ? 'inaccessible' : 'outdated' - }) - ]) - ).toEqual([]) - } - ) - - it('withholds the update when the convergent copy itself is inaccessible', () => { - expect( - eligibleSkillUpdateNames([ - placement('orca-cli', { id: 'blocked-canonical', status: 'inaccessible' }), - placement('orca-cli', { - id: 'orca-cli-claude', - rootId: 'home-claude', - topology: 'provider-alias', - status: 'outdated' - }) - ]) - ).toEqual([]) - }) - - it('does not promise an update when only a standalone duplicate is outdated', () => { - expect( - eligibleSkillUpdateNames([ - placement('orchestration', { status: 'current' }), - placement('orchestration', { - id: 'orchestration-factory', - rootId: 'home-factory', - unresolvedPath: '/home/.factory/skills/orchestration', - resolvedPath: '/home/.factory/skills/orchestration', - physicalIdentity: 'physical-orchestration-factory', - topology: 'independent-copy', - status: 'outdated' - }) - ]) - ).toEqual([]) - }) - - it('does not offer a skill that exists only as a standalone copy', () => { - expect( - eligibleSkillUpdateNames([ - placement('orca-cli', { - rootId: 'home-gemini', - unresolvedPath: '/home/.gemini/skills/orca-cli', - resolvedPath: '/home/.gemini/skills/orca-cli', - topology: 'independent-copy' - }) - ]) - ).toEqual([]) - }) - - it('scopes each name independently and leaves an all-current name alone', () => { - expect( - eligibleSkillUpdateNames([ - placement('computer-use', { status: 'current' }), - placement('orchestration'), - placement('orchestration', { id: 'orchestration-project', topology: 'repo-scope' }) - ]) - ).toEqual(['orchestration']) - }) - - it('canonicalizes only names that are safe to put on a command line', () => { - expect(canonicalizeSkillUpdateNames(['orchestration', 'orca-cli', 'orca-cli'])).toEqual([ - 'orca-cli', - 'orchestration' - ]) - expect(canonicalizeSkillUpdateNames([])).toBeNull() - expect(canonicalizeSkillUpdateNames(['orca-cli;echo unsafe'])).toBeNull() - }) -}) diff --git a/src/main/skills/skill-freshness-eligibility.ts b/src/main/skills/skill-freshness-eligibility.ts deleted file mode 100644 index 0d4e92abe27..00000000000 --- a/src/main/skills/skill-freshness-eligibility.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, - type SkillFreshnessInstallation, - type SkillInstallationTopology -} from '../../shared/skill-freshness' - -// Why: `skills add` rewrites the skill in every agent folder it targets with an rm-then-copy. -// Through a link out of tree that deletes someone else's files (#22897); into a read-only or -// dangling placement it fails half-way. Any such home placement keeps the name skipped. -const UNWRITABLE_HOME_TOPOLOGIES: ReadonlySet = new Set([ - 'external-link', - 'broken-link', - 'read-only' -]) - -/** - * Names the pinned install can safely bring to this build's version. - * - * Ownership is deliberately not consulted: whether to preselect a copy someone else - * installed is presentation policy. Copies the command never writes (project skills, - * plugin caches) neither authorize an update nor withhold one. - */ -export function eligibleSkillUpdateNames( - installations: readonly SkillFreshnessInstallation[] -): string[] { - const byName = new Map() - for (const installation of installations) { - const entries = byName.get(installation.name) ?? [] - entries.push(installation) - byName.set(installation.name, entries) - } - - const eligible: string[] = [] - for (const [name, entries] of byName) { - const convergent = entries.filter((entry) => - SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) - ) - if ( - convergent.some((entry) => entry.status === 'outdated') && - convergent.every((entry) => entry.status !== 'inaccessible') && - entries.every((entry) => !UNWRITABLE_HOME_TOPOLOGIES.has(entry.topology)) - ) { - eligible.push(name) - } - } - return eligible.sort((left, right) => left.localeCompare(right, 'en')) -} diff --git a/src/main/skills/skill-freshness-inventory-limits.test.ts b/src/main/skills/skill-freshness-inventory-limits.test.ts deleted file mode 100644 index b818f968238..00000000000 --- a/src/main/skills/skill-freshness-inventory-limits.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - boundRepositorySkillRoots, - MAXIMUM_REPOSITORY_SKILL_ROOTS -} from './skill-freshness-inventory' -import type { SkillScanRoot } from './skill-discovery-sources' - -describe('skill freshness inventory limits', () => { - it('caps repository roots before creating candidate probes', () => { - const roots = Array.from( - { length: MAXIMUM_REPOSITORY_SKILL_ROOTS + 3 }, - (_, index): SkillScanRoot => ({ - id: `repo-${index}`, - label: `Repo ${index}`, - path: `/repo-${index}/.agents/skills`, - sourceKind: 'repo', - providers: ['agent-skills'], - owner: null - }) - ) - - const bounded = boundRepositorySkillRoots(roots) - - expect(bounded.scanned).toHaveLength(MAXIMUM_REPOSITORY_SKILL_ROOTS) - expect(bounded.omitted).toHaveLength(3) - }) -}) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts deleted file mode 100644 index 315c4fd15fd..00000000000 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../shared/repo-types' -import type { SkillBundleFileIdentity, SkillCurrentBundleEntry } from '../../shared/skill-freshness' -import { - inventorySkillFreshness, - MAXIMUM_REPOSITORY_SKILL_ROOTS -} from './skill-freshness-inventory' -import { describeObservedSkillFile, skillPackageDigest } from './skill-package-identity' -import { MAXIMUM_PLUGIN_SCAN_ENTRIES } from './skill-plugin-cache-scan' -import { getSkillFreshnessDisplayStatus } from '../../renderer/src/lib/skill-freshness-display-status' - -const temporaryDirectories: string[] = [] - -function bundleEntry(markdown: string): SkillCurrentBundleEntry { - const observed = describeObservedSkillFile('SKILL.md', Buffer.from(markdown), false) - const file: SkillBundleFileIdentity = { - path: observed.path, - size: observed.size, - executable: observed.executable, - classification: observed.classification, - exactSha256: observed.exactSha256, - textNormalizedSha256: observed.textNormalizedSha256, - identitySha256: observed.identitySha256 - } - return { - name: 'orca-cli', - sourcePath: 'skills/orca-cli', - packageDigest: skillPackageDigest([file]), - files: [file] - } -} - -async function writeSkillLock( - homeDir: string, - entry: { source?: string; skillFolderHash?: string } | null -): Promise { - await mkdir(join(homeDir, '.agents'), { recursive: true }) - await writeFile( - join(homeDir, '.agents', '.skill-lock.json'), - `${JSON.stringify({ - version: 3, - skills: entry ? { 'orca-cli': { skillPath: 'skills/orca-cli/SKILL.md', ...entry } } : {} - })}\n` - ) -} - -async function fixture() { - const root = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-')) - temporaryDirectories.push(root) - const homeDir = join(root, 'home') - const resourceRoot = join(root, 'resources') - await mkdir(join(resourceRoot, 'skills'), { recursive: true }) - - const oldMarkdown = '---\nname: orca-cli\ndescription: Old official guide.\n---\n\n# Old\n' - const currentMarkdown = - '---\nname: orca-cli\ndescription: Current official guide.\n---\n\n# Current\n' - // Why: what an install between releases left; no list of shipped versions knows it. - const unreleasedMarkdown = - '---\nname: orca-cli\ndescription: Main between releases.\n---\n\n# Unreleased\n' - await Promise.all([ - writeSkillLock(homeDir, { source: 'stablyai/orca', skillFolderHash: 'b'.repeat(40) }), - writeFile( - join(resourceRoot, 'skills', 'current-manifest.json'), - `${JSON.stringify({ schemaVersion: 3, skills: [bundleEntry(currentMarkdown)] }, null, 2)}\n` - ) - ]) - - const writeSkill = async (rootPath: string, markdown: string): Promise => { - const directory = join(rootPath, 'orca-cli') - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'SKILL.md'), markdown) - return directory - } - const scan = (overrides: { repos?: Repo[] } = {}) => - inventorySkillFreshness({ - homeDir, - repos: overrides.repos ?? [], - resourceRoot, - stateHome: null - }) - return { - root, - homeDir, - resourceRoot, - oldMarkdown, - currentMarkdown, - unreleasedMarkdown, - writeSkill, - scan - } -} - -beforeEach(() => { - // Why: provider roots follow these variables; the host's real values would point the - // scan at the developer's own skill folders. - for (const name of ['CLAUDE_CONFIG_DIR', 'GROK_HOME', 'HERMES_HOME', 'XDG_STATE_HOME']) { - vi.stubEnv(name, '') - } -}) - -afterEach(async () => { - vi.unstubAllEnvs() - await Promise.all( - temporaryDirectories - .splice(0) - .map((root) => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) - ) -}) - -const linkType = process.platform === 'win32' ? 'junction' : 'dir' - -describe('read-only skill freshness inventory', () => { - it('reads an older official copy as outdated and offers it', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - - const inventory = await test.scan() - - expect(inventory.installations.map((entry) => entry.status)).toEqual(['outdated']) - expect(inventory.installations[0]?.lockSource).toBe('stablyai/orca') - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('update-available') - }) - - it('reads the shipped bytes as current, with CRLF or without', async () => { - for (const markdown of [ - (await fixture()).currentMarkdown, - (await fixture()).currentMarkdown.replaceAll('\n', '\r\n') - ]) { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), markdown) - - const inventory = await test.scan() - - expect(inventory.installations.map((entry) => entry.status)).toEqual(['current']) - expect(inventory.eligibleUpdateNames).toEqual([]) - expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') - } - }) - - // #26841: each of these stranded an untouched Orca-installed copy as "modified". - describe('an Orca-owned copy of another version reads outdated and eligible (#26841)', () => { - it('with bytes from a version no release list knows', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.unreleasedMarkdown) - - const inventory = await test.scan() - - expect(inventory.installations[0]).toMatchObject({ - topology: 'canonical-copy', - status: 'outdated' - }) - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('update-available') - }) - - it('when the skills CLI recorded a sha256 lock hash', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.unreleasedMarkdown) - await writeSkillLock(test.homeDir, { - source: 'stablyai/orca', - skillFolderHash: '7ad2f1fd'.repeat(8) - }) - - const inventory = await test.scan() - - expect(inventory.installations[0]?.status).toBe('outdated') - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - - it('with CRLF line endings', async () => { - const test = await fixture() - await test.writeSkill( - join(test.homeDir, '.agents', 'skills'), - test.unreleasedMarkdown.replaceAll('\n', '\r\n') - ) - - const inventory = await test.scan() - - expect(inventory.installations[0]?.status).toBe('outdated') - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - - it('behind a .cac skills junction to .agents skills', async () => { - const test = await fixture() - const canonicalRoot = join(test.homeDir, '.agents', 'skills') - await test.writeSkill(canonicalRoot, test.unreleasedMarkdown) - const claudeConfig = join(test.homeDir, '.cac') - await mkdir(claudeConfig, { recursive: true }) - await symlink(canonicalRoot, join(claudeConfig, 'skills'), linkType) - vi.stubEnv('CLAUDE_CONFIG_DIR', claudeConfig) - - const inventory = await test.scan() - - expect(inventory.installations).toHaveLength(1) - expect(inventory.installations[0]).toMatchObject({ - topology: 'canonical-copy', - status: 'outdated', - providers: ['agent-skills', 'claude'] - }) - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - }) - - it.each([ - ['another source', { source: 'acme/agent-skills' }, 'acme/agent-skills'], - ['no source', {}, null], - ['no lock entry', null, null] - ])( - 'offers a same-named copy from %s without attributing it to Orca', - async (_label, entry, lockSource) => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - await writeSkillLock(test.homeDir, entry) - - const inventory = await test.scan() - - // Why: eligibility is about safety; preselection reads the ownership each row carries. - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - expect(inventory.installations[0]?.lockSource).toBe(lockSource) - expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('installed') - } - ) - - it('keeps a link to a folder outside the skill homes skipped', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - const dotfiles = await test.writeSkill(join(test.root, 'dotfiles'), test.oldMarkdown) - const claudeRoot = join(test.homeDir, '.claude', 'skills') - await mkdir(claudeRoot, { recursive: true }) - await symlink(dotfiles, join(claudeRoot, 'orca-cli'), linkType) - - const inventory = await test.scan() - - expect(inventory.installations).toEqual( - expect.arrayContaining([expect.objectContaining({ topology: 'external-link' })]) - ) - // Why: `skills add` rm-then-copies through the link and deletes the dotfiles copy (#22897). - expect(inventory.eligibleUpdateNames).toEqual([]) - expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('needs-attention') - }) - - it('reads current beside OS and agent CLI sidecars the bundle does not list', async () => { - const test = await fixture() - const directory = await test.writeSkill( - join(test.homeDir, '.agents', 'skills'), - test.currentMarkdown - ) - await writeFile(join(directory, '.DS_Store'), Buffer.from([0, 1, 2, 3])) - await mkdir(join(directory, 'agents'), { recursive: true }) - await writeFile(join(directory, 'agents', 'openai.yaml'), 'display_name: test\n') - - const inventory = await test.scan() - - expect(inventory.installations.map((entry) => entry.status)).toEqual(['current']) - expect(inventory.eligibleUpdateNames).toEqual([]) - }) - - it('reads a local edit as outdated, not as a separate verdict', async () => { - const test = await fixture() - await test.writeSkill( - join(test.homeDir, '.agents', 'skills'), - `${test.currentMarkdown}\nLocal edit.\n` - ) - - const inventory = await test.scan() - - expect(inventory.installations[0]?.status).toBe('outdated') - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - - it('deduplicates a provider alias to the canonical copy', async () => { - const test = await fixture() - const canonical = await test.writeSkill( - join(test.homeDir, '.agents', 'skills'), - test.oldMarkdown - ) - const claudeRoot = join(test.homeDir, '.claude', 'skills') - await mkdir(claudeRoot, { recursive: true }) - await symlink(canonical, join(claudeRoot, 'orca-cli'), linkType) - - const inventory = await test.scan() - - expect(inventory.installations).toHaveLength(1) - expect(inventory.installations[0]?.providers).toEqual(['agent-skills', 'claude']) - expect(inventory.installations[0]?.topology).toBe('canonical-copy') - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - - it('deduplicates aliases within an unsupported topology while still updating the canonical copy', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - const shared = await test.writeSkill(join(test.root, 'shared'), test.currentMarkdown) - const repos = await Promise.all( - ['one', 'two'].map(async (id) => { - const repoPath = join(test.root, `repo-${id}`) - const root = join(repoPath, '.agents', 'skills') - await mkdir(root, { recursive: true }) - await symlink(shared, join(root, 'orca-cli'), linkType) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scan reads only id and path. - return { id, path: repoPath } as Repo - }) - ) - - const inventory = await test.scan({ repos }) - - expect(inventory.installations.filter((entry) => entry.topology === 'repo-scope')).toHaveLength( - 1 - ) - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - - it('keeps an unreadable home placement visible and the name skipped', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli') - - const inventory = await inventorySkillFreshness({ - homeDir: test.homeDir, - repos: [], - resourceRoot: test.resourceRoot, - stateHome: null, - candidateLstat: async (path) => { - if (path === inaccessiblePath) { - throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) - } - return import('node:fs/promises').then(({ lstat }) => lstat(path)) - } - }) - - expect(inventory.installations.map((entry) => [entry.topology, entry.status])).toEqual([ - ['canonical-copy', 'outdated'], - ['broken-link', 'inaccessible'] - ]) - // Why: `skills add` would write through that placement for the codex target. - expect(inventory.eligibleUpdateNames).toEqual([]) - }) - - it('does not lose an inaccessible known repository placement', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - const repoPath = join(test.root, 'repo') - const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli') - - const inventory = await inventorySkillFreshness({ - homeDir: test.homeDir, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scan reads only id and path. - repos: [{ id: 'repo', path: repoPath } as Repo], - resourceRoot: test.resourceRoot, - stateHome: null, - candidateLstat: async (path) => { - if (path === inaccessiblePath) { - throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) - } - return import('node:fs/promises').then(({ lstat }) => lstat(path)) - } - }) - - expect(inventory.installations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - unresolvedPath: inaccessiblePath, - topology: 'repo-scope', - status: 'inaccessible' - }) - ]) - ) - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - - it.each([ - ['repo', 'repo-scope'], - ['plugin', 'plugin-cache'] - ] as const)( - 'keeps an official %s placement informational without withholding the update', - async (kind, topology) => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - let repos: Repo[] = [] - if (kind === 'repo') { - const repoPath = join(test.root, 'repo') - await test.writeSkill(join(repoPath, '.agents', 'skills'), test.currentMarkdown) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scan reads only id and path. - repos = [{ id: 'repo', path: repoPath } as Repo] - } else { - await test.writeSkill( - join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'skills'), - test.currentMarkdown - ) - } - - const inventory = await test.scan({ repos }) - - expect(inventory.installations.some((entry) => entry.topology === topology)).toBe(true) - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - } - ) - - it('keeps another ecosystem’s same-name plugin skill out of the badge', async () => { - // Why: Codex ships its own `computer-use` plugin (#10633). Orca never writes plugin - // caches and cannot tell an old release from another vendor's skill. - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const pluginRoot = join( - test.homeDir, - '.codex', - 'plugins', - 'cache', - 'openai-bundled', - 'orca-cli' - ) - await mkdir(pluginRoot, { recursive: true }) - await writeFile(join(pluginRoot, 'SKILL.md'), '---\nname: orca-cli\n---\n\nAnother tool.\n') - - const inventory = await test.scan() - - expect(inventory.installations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ unresolvedPath: pluginRoot, topology: 'plugin-cache' }) - ]) - ) - expect(inventory.eligibleUpdateNames).toEqual([]) - expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') - }) - - it('does not classify an empty plugin-cache directory as a skill', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const emptyRoot = join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'orca-cli') - await mkdir(emptyRoot, { recursive: true }) - - const inventory = await test.scan() - - expect(inventory.installations.some((entry) => entry.unresolvedPath === emptyRoot)).toBe(false) - }) - - it('reports the repository scan limit without withholding the global update', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - const repos = Array.from( - { length: MAXIMUM_REPOSITORY_SKILL_ROOTS / 2 + 1 }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scan reads only id and path. - (_, index) => ({ id: `repo-${index}`, path: join(test.root, `repo-${index}`) }) as Repo - ) - - const inventory = await test.scan({ repos }) - - expect(inventory.installations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ errorCategory: 'repository-scan-limit', status: 'inaccessible' }) - ]) - ) - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - - it('scans a real-shaped plugin cache completely and leaves eligibility unchanged', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) - const packageRoot = join( - test.homeDir, - '.codex', - 'plugins', - 'cache', - 'openai-bundled', - 'orca-cli', - '1.0.0' - ) - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills/"}\n') - const pluginSkill = await test.writeSkill(join(packageRoot, 'skills'), test.currentMarkdown) - await mkdir(join(pluginSkill, 'templates', 'starter', 'examples', 'd1', 'app', 'api'), { - recursive: true - }) - - const inventory = await test.scan() - - expect(inventory.scanIssues).toEqual([]) - expect(inventory.installations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ topology: 'plugin-cache', unresolvedPath: pluginSkill }) - ]) - ) - expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) - }) - - it('reports incomplete plugin coverage without inventing per-skill installations', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const pluginCache = join(test.homeDir, '.codex', 'plugins', 'cache') - await mkdir(join(pluginCache, ...Array.from({ length: 11 }, (_, index) => `level-${index}`)), { - recursive: true - }) - - const inventory = await test.scan() - - expect(inventory.installations).toHaveLength(1) - expect(inventory.installations[0]).toMatchObject({ - name: 'orca-cli', - status: 'current', - topology: 'canonical-copy' - }) - expect(inventory.scanIssues).toEqual([ - expect.objectContaining({ - rootId: 'codex-plugin-cache', - sourceLabel: 'Codex plugin cache', - reason: 'depth-limit', - errorCode: null - }) - ]) - }) - - it.skipIf(process.platform === 'win32')( - 'invents no installations when the plugin cache trips the entry budget (#10918)', - async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const pluginCache = join(test.homeDir, '.codex', 'plugins', 'cache') - await mkdir(pluginCache, { recursive: true }) - const entries = Array.from({ length: MAXIMUM_PLUGIN_SCAN_ENTRIES + 1 }, (_, index) => - join(pluginCache, `entry-${index}`) - ) - for (let index = 0; index < entries.length; index += 512) { - await Promise.all(entries.slice(index, index + 512).map((path) => writeFile(path, ''))) - } - - const inventory = await test.scan() - - expect(inventory.scanIssues).toEqual([ - expect.objectContaining({ - rootId: 'codex-plugin-cache', - path: pluginCache, - reason: 'entry-limit', - errorCode: null - }) - ]) - expect(inventory.installations).toEqual([ - expect.objectContaining({ name: 'orca-cli', status: 'current', topology: 'canonical-copy' }) - ]) - }, - 90_000 - ) -}) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts deleted file mode 100644 index 52cf2c1d05a..00000000000 --- a/src/main/skills/skill-freshness-inventory.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { lstat } from 'node:fs/promises' -import { join } from 'node:path' -import type { Repo } from '../../shared/repo-types' -import type { - SkillFreshnessInstallation, - SkillFreshnessInventory -} from '../../shared/skill-freshness' -import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources' -import { loadSkillBundleManifest } from './skill-bundle-artifacts' -import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' -import { runSkillCandidateTasks } from './skill-candidate-concurrency' -import { - classifyHomeSkillCandidate, - classifyUnsupportedSkillCandidate, - dedupeSkillFreshnessPlacements, - observeSkillFreshnessInstallation, - type CandidateLstat -} from './skill-freshness-placement-observation' -import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' -import { readSkillLockSources } from './skill-lock-sources' - -export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 - -export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): { - scanned: SkillScanRoot[] - omitted: SkillScanRoot[] -} { - return { - scanned: roots.slice(0, MAXIMUM_REPOSITORY_SKILL_ROOTS), - omitted: roots.slice(MAXIMUM_REPOSITORY_SKILL_ROOTS) - } -} - -export async function inventorySkillFreshness(args: { - homeDir?: string - cwd?: string - repos?: Repo[] - resourceRoot?: string - candidateLstat?: CandidateLstat - stateHome?: string | null -}): Promise { - const [manifest, lockSources] = await Promise.all([ - loadSkillBundleManifest(args.resourceRoot), - readSkillLockSources({ homeDir: args.homeDir, stateHome: args.stateHome }) - ]) - const currentByName = new Map(manifest.skills.map((skill) => [skill.name, skill])) - const lockSourceOf = (name: string): string | null => lockSources.get(name) ?? null - const discoveryArgs = { - homeDir: args.homeDir, - cwd: args.cwd, - repos: args.repos, - // Why: freshness scans known repositories explicitly; treating the app's - // launch cwd as another repo would create phantom poison placements. - includeCwd: false - } - const roots = buildSkillDiscoverySources(discoveryArgs) - const homeRoots = roots.filter((root) => root.sourceKind === 'home') - const allRepoRoots = roots.filter((root) => root.sourceKind === 'repo') - const { scanned: repoRoots, omitted: omittedRepoRoots } = boundRepositorySkillRoots(allRepoRoots) - const pluginRoots = roots.filter((root) => root.sourceKind === 'plugin') - const canonicalRootPath = homeRoots.find((root) => root.id === 'home-agents')?.path - if (!canonicalRootPath) { - throw new Error('Missing canonical agent skills root') - } - - const homeTasks = manifest.skills.flatMap((current) => - homeRoots.map( - (root) => () => - classifyHomeSkillCandidate({ - root, - current, - lockSource: lockSourceOf(current.name), - canonicalRootPath, - candidateLstat: args.candidateLstat ?? ((path) => lstat(path)) - }) - ) - ) - // Why: each observation may retain the package byte ceiling while hashing; - // launch/focus scans must not fan out across every known placement. - const homeInstallations = (await runSkillCandidateTasks(homeTasks)).filter( - (installation): installation is SkillFreshnessInstallation => installation !== null - ) - - const candidateLstat = args.candidateLstat ?? ((path) => lstat(path)) - const repoTasks = manifest.skills.flatMap((current) => - repoRoots.map( - (root) => () => - classifyUnsupportedSkillCandidate({ - root, - current, - lockSource: lockSourceOf(current.name), - unresolvedPath: join(root.path, current.name), - candidateLstat - }) - ) - ) - // Why: stored repositories can grow without bound. If the probe budget is - // exhausted, one sentinel per name preserves safety without hashing more packages. - const omittedRepoTasks = - omittedRepoRoots.length === 0 - ? [] - : manifest.skills.map( - (current) => () => - observeSkillFreshnessInstallation({ - current, - lockSource: lockSourceOf(current.name), - rootId: 'repo-scan-limit', - providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))], - sourceKind: 'repo', - sourceLabel: 'Additional repositories', - unresolvedPath: omittedRepoRoots[0]?.path ?? 'repo-scan-limit', - topology: { - topology: 'repo-scope', - resolvedPath: null, - identity: null, - errorCategory: 'repository-scan-limit' - } - }) - ) - const pluginScans = await Promise.all( - pluginRoots.map(async (root) => ({ - root, - scan: await scanKnownPluginSkillCandidates(root.path, new Set(currentByName.keys())) - })) - ) - const pluginTasks = pluginScans.flatMap(({ root, scan }) => - scan.candidates.flatMap((candidate) => { - const current = currentByName.get(candidate.name) - return current - ? [ - () => - classifyUnsupportedSkillCandidate({ - root, - current, - lockSource: lockSourceOf(current.name), - unresolvedPath: candidate.path, - candidateLstat - }) - ] - : [] - }) - ) - const scanIssues = pluginScans.flatMap(({ root, scan }) => - scan.issues.map((issue) => ({ - rootId: root.id, - sourceLabel: root.label, - ...issue - })) - ) - const unsupportedInstallations = ( - await runSkillCandidateTasks([...repoTasks, ...omittedRepoTasks, ...pluginTasks]) - ).filter((installation): installation is SkillFreshnessInstallation => installation !== null) - const installations = dedupeSkillFreshnessPlacements([ - ...homeInstallations, - ...unsupportedInstallations - ]).sort( - (left, right) => - left.name.localeCompare(right.name, 'en') || - left.unresolvedPath.localeCompare(right.unresolvedPath, 'en') - ) - - return { - schemaVersion: 1, - installations, - eligibleUpdateNames: eligibleSkillUpdateNames(installations), - scanIssues, - scannedAt: Date.now() - } -} diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts deleted file mode 100644 index ba85c660522..00000000000 --- a/src/main/skills/skill-freshness-placement-observation.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { Stats } from 'node:fs' -import { join } from 'node:path' -import type { - SkillCurrentBundleEntry, - SkillFreshnessInstallation -} from '../../shared/skill-freshness' -import type { SkillScanRoot } from './skill-discovery-sources' -import { matchesBundledSkillFiles, observeSkillPackage } from './skill-package-identity' -import { - classifyHomeSkillTopology, - classifyUnsupportedSkillTopology, - skillPlacementId, - skillTopologyPriority, - type ClassifiedSkillTopology -} from './skill-installation-topology' - -export type CandidateLstat = (path: string) => Promise - -function errorCategory(error: unknown, fallback: string): string { - return error instanceof Error && error.message ? error.message : fallback -} - -export async function observeSkillFreshnessInstallation(args: { - current: SkillCurrentBundleEntry - lockSource: string | null - rootId: string - providers: SkillFreshnessInstallation['providers'] - sourceKind: SkillFreshnessInstallation['sourceKind'] - sourceLabel: string - unresolvedPath: string - topology: ClassifiedSkillTopology -}): Promise { - const base = { - id: skillPlacementId(args.unresolvedPath, args.current.name), - name: args.current.name, - rootId: args.rootId, - providers: args.providers, - sourceKind: args.sourceKind, - sourceLabel: args.sourceLabel, - unresolvedPath: args.unresolvedPath, - resolvedPath: args.topology.resolvedPath, - physicalIdentity: args.topology.identity, - topology: args.topology.topology, - currentPackageDigest: args.current.packageDigest, - lockSource: args.lockSource, - errorCategory: args.topology.errorCategory - } - if (!args.topology.resolvedPath || !args.topology.identity) { - return { ...base, status: 'inaccessible' } - } - - try { - const observed = await observeSkillPackage(args.topology.resolvedPath) - // Why: the only question is whether this is the version this build ships. Any - // difference, older release, unreleased commit or local edit alike, is outdated. - return { - ...base, - status: matchesBundledSkillFiles(observed, args.current.files) ? 'current' : 'outdated' - } - } catch (error) { - // Why: a package Orca could not read is never offered for overwrite on a guess. - return { - ...base, - status: 'inaccessible', - errorCategory: errorCategory(error, 'skill-package-read-failed') - } - } -} - -export async function classifyHomeSkillCandidate(args: { - root: SkillScanRoot - current: SkillCurrentBundleEntry - lockSource: string | null - canonicalRootPath: string - candidateLstat: CandidateLstat -}): Promise { - const unresolvedPath = join(args.root.path, args.current.name) - try { - await args.candidateLstat(unresolvedPath) - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - return null - } - return observeSkillFreshnessInstallation({ - current: args.current, - lockSource: args.lockSource, - rootId: args.root.id, - providers: args.root.providers, - sourceKind: args.root.sourceKind, - sourceLabel: args.root.label, - unresolvedPath, - topology: { - topology: 'broken-link', - resolvedPath: null, - identity: null, - errorCategory: errorCategory(error, 'skill-candidate-inaccessible') - } - }) - } - - let topology: ClassifiedSkillTopology - try { - topology = await classifyHomeSkillTopology(args.root, unresolvedPath, args.canonicalRootPath) - } catch (error) { - topology = { - topology: 'broken-link', - resolvedPath: null, - identity: null, - errorCategory: errorCategory(error, 'skill-candidate-topology-failed') - } - } - return observeSkillFreshnessInstallation({ - current: args.current, - lockSource: args.lockSource, - rootId: args.root.id, - providers: args.root.providers, - sourceKind: args.root.sourceKind, - sourceLabel: args.root.label, - unresolvedPath, - topology - }) -} - -export async function classifyUnsupportedSkillCandidate(args: { - root: SkillScanRoot - current: SkillCurrentBundleEntry - lockSource: string | null - unresolvedPath: string - candidateLstat: CandidateLstat -}): Promise { - try { - await args.candidateLstat(args.unresolvedPath) - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - return null - } - return observeSkillFreshnessInstallation({ - current: args.current, - lockSource: args.lockSource, - rootId: args.root.id, - providers: args.root.providers, - sourceKind: args.root.sourceKind, - sourceLabel: args.root.label, - unresolvedPath: args.unresolvedPath, - topology: { - topology: args.root.sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', - resolvedPath: null, - identity: null, - errorCategory: errorCategory(error, 'unsupported-candidate-inaccessible') - } - }) - } - return observeSkillFreshnessInstallation({ - current: args.current, - lockSource: args.lockSource, - rootId: args.root.id, - providers: args.root.providers, - sourceKind: args.root.sourceKind, - sourceLabel: args.root.label, - unresolvedPath: args.unresolvedPath, - topology: await classifyUnsupportedSkillTopology( - args.unresolvedPath, - args.root.sourceKind === 'repo' ? 'repo' : 'plugin' - ) - }) -} - -function topologyDedupeBucket(installation: SkillFreshnessInstallation): string { - return installation.topology === 'canonical-copy' || installation.topology === 'provider-alias' - ? 'managed-global' - : installation.topology -} - -export function dedupeSkillFreshnessPlacements( - installations: readonly SkillFreshnessInstallation[] -): SkillFreshnessInstallation[] { - const deduped = new Map() - for (const installation of installations) { - const key = installation.physicalIdentity - ? `${installation.name}\0${installation.physicalIdentity}\0${topologyDedupeBucket(installation)}` - : `logical\0${installation.id}` - const existing = deduped.get(key) - if (!existing) { - deduped.set(key, installation) - continue - } - const providers = [...new Set([...existing.providers, ...installation.providers])] - if (skillTopologyPriority(installation.topology) > skillTopologyPriority(existing.topology)) { - deduped.set(key, { ...installation, providers }) - } else { - existing.providers = providers - } - } - return [...deduped.values()] -} diff --git a/src/main/skills/skill-installation-topology.ts b/src/main/skills/skill-installation-topology.ts deleted file mode 100644 index 3b86452148c..00000000000 --- a/src/main/skills/skill-installation-topology.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { createHash } from 'node:crypto' -import { constants, type Stats } from 'node:fs' -import { access, lstat, realpath, stat } from 'node:fs/promises' -import { dirname, normalize, resolve } from 'node:path' -import type { SkillInstallationTopology } from '../../shared/skill-freshness' -import type { SkillScanRoot } from './skill-discovery-sources' - -export type ClassifiedSkillTopology = { - topology: SkillInstallationTopology - resolvedPath: string | null - identity: string | null - errorCategory: string | null -} - -export function skillPlacementId(unresolvedPath: string, name: string): string { - return createHash('sha256') - .update(normalizedSkillIdentityPath(unresolvedPath)) - .update('\0') - .update(name) - .digest('hex') - .slice(0, 24) -} - -export function normalizedSkillIdentityPath(value: string): string { - const normalized = normalize(value) - return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized -} - -export function skillPhysicalIdentity(resolvedPath: string, fileStat: Stats): string { - const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null - return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath) -} - -export function skillTopologyPriority(topology: SkillInstallationTopology): number { - switch (topology) { - case 'canonical-copy': - return 3 - case 'independent-copy': - return 2 - case 'provider-alias': - return 1 - case 'external-link': - case 'broken-link': - case 'read-only': - case 'repo-scope': - case 'plugin-cache': - return 0 - } -} - -async function writableDestination(path: string): Promise { - try { - await Promise.all([ - access(path, constants.R_OK | constants.W_OK), - access(dirname(path), constants.W_OK) - ]) - return true - } catch { - return false - } -} - -async function hasSymlinkedAncestor(path: string, boundary: string): Promise { - let current = resolve(path) - const stop = resolve(boundary) - for (;;) { - const entry = await lstat(current).catch(() => null) - if (!entry || entry.isSymbolicLink()) { - return true - } - const parent = dirname(current) - if (current === stop) { - return false - } - if (parent === current) { - return true - } - current = parent - } -} - -export async function classifyHomeSkillTopology( - root: SkillScanRoot, - unresolvedPath: string, - canonicalRootPath: string -): Promise { - let logicalStat: Awaited> - try { - logicalStat = await lstat(unresolvedPath) - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - return { - topology: 'broken-link', - resolvedPath: null, - identity: null, - errorCategory: 'missing' - } - } - throw error - } - const linked = logicalStat.isSymbolicLink() - let resolvedPath: string - let resolvedStat: Awaited> - try { - resolvedPath = await realpath(unresolvedPath) - resolvedStat = await stat(resolvedPath) - } catch { - return { - topology: 'broken-link', - resolvedPath: null, - identity: null, - errorCategory: 'dangling-link' - } - } - if (!resolvedStat.isDirectory()) { - return { - topology: 'broken-link', - resolvedPath, - identity: null, - errorCategory: 'not-directory' - } - } - - const identity = skillPhysicalIdentity(resolvedPath, resolvedStat) - const canonicalRoot = await realpath(canonicalRootPath).catch(() => resolve(canonicalRootPath)) - const homeBoundary = dirname(dirname(canonicalRootPath)) - const rootOrProviderParentLinked = await hasSymlinkedAncestor(root.path, homeBoundary) - const isCanonicalTarget = - normalizedSkillIdentityPath(dirname(resolvedPath)) === - normalizedSkillIdentityPath(canonicalRoot) - let topology: SkillInstallationTopology - if (linked || rootOrProviderParentLinked) { - topology = isCanonicalTarget ? 'provider-alias' : 'external-link' - } else { - topology = root.id === 'home-agents' ? 'canonical-copy' : 'independent-copy' - } - if (topology !== 'external-link' && !(await writableDestination(resolvedPath))) { - topology = 'read-only' - } - return { topology, resolvedPath, identity, errorCategory: null } -} - -export async function classifyUnsupportedSkillTopology( - directoryPath: string, - sourceKind: 'repo' | 'plugin' -): Promise { - try { - const resolvedPath = await realpath(directoryPath) - const resolvedStat = await stat(resolvedPath) - if (!resolvedStat.isDirectory()) { - throw new Error('not-directory') - } - return { - topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', - resolvedPath, - identity: skillPhysicalIdentity(resolvedPath, resolvedStat), - errorCategory: null - } - } catch (error) { - return { - topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', - resolvedPath: null, - identity: null, - errorCategory: error instanceof Error ? error.message : 'read-failed' - } - } -} diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index bde3685ea1e..6776d03a026 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -2,7 +2,16 @@ import { createHash } from 'node:crypto' import type { Dirent } from 'node:fs' import { lstat, open, opendir } from 'node:fs/promises' import { isAbsolute, join, relative, sep } from 'node:path' -import type { SkillBundleFileIdentity } from '../../shared/skill-freshness' + +export type SkillBundleFileIdentity = { + path: string + size: number + executable: boolean + classification: 'text' | 'binary' + exactSha256: string + textNormalizedSha256: string | null + identitySha256: string +} type ObservedSkillFile = SkillBundleFileIdentity diff --git a/src/main/skills/skill-plugin-cache-scan.test.ts b/src/main/skills/skill-plugin-cache-scan.test.ts deleted file mode 100644 index 6434967aa4d..00000000000 --- a/src/main/skills/skill-plugin-cache-scan.test.ts +++ /dev/null @@ -1,895 +0,0 @@ -import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { promisify } from 'node:util' -import { afterEach, describe, expect, it } from 'vitest' -import { isSkillScanIssueNeedingAttention } from '../../shared/skill-freshness' -import { - MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES, - MAXIMUM_PLUGIN_SCAN_DEPTH, - MAXIMUM_PLUGIN_SCAN_ISSUES, - scanKnownPluginSkillCandidates -} from './skill-plugin-cache-scan' - -const temporaryDirectories: string[] = [] -const execFileAsync = promisify(execFile) - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) -}) - -describe('plugin skill candidate scan', () => { - it('stops at the package candidate budget and marks the scan incomplete', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-scan-')) - temporaryDirectories.push(root) - await Promise.all( - ['one', 'two'].map(async (vendor) => { - await mkdir(join(root, vendor, 'orca-cli'), { recursive: true }) - await writeFile(join(root, vendor, 'orca-cli', 'SKILL.md'), '# Orca CLI\n') - }) - ) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), { - maximumCandidates: 1 - }) - - expect(result.candidates).toHaveLength(1) - expect(result.issues).toEqual([{ path: root, reason: 'candidate-limit', errorCode: null }]) - }) - - it('stops at the entry budget and reports the truncation at the scan root', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-entry-limit-')) - temporaryDirectories.push(root) - await Promise.all( - ['one', 'two'].map(async (vendor) => { - await mkdir(join(root, vendor, 'orca-cli'), { recursive: true }) - await writeFile(join(root, vendor, 'orca-cli', 'SKILL.md'), '# Orca CLI\n') - }) - ) - - // Budget: the root's two vendor dirents, one's, and its skill's — so the count is - // crossed inside 'two', not at the root. That is what pins the issue to the scan - // root the dialog can name rather than whichever directory happened to cross it. - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), { - maximumEntries: 4 - }) - - // Why: the second vendor's skill goes unseen. The issue is all that stops the dialog - // reporting all-clear over a scan that never reached it. - expect(result.candidates).toEqual([{ name: 'orca-cli', path: join(root, 'one', 'orca-cli') }]) - expect(result.issues).toEqual([{ path: root, reason: 'entry-limit', errorCode: null }]) - }) - - it('stops walking the directory whose read crossed the entry budget', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-entry-limit-stop-')) - temporaryDirectories.push(root) - // Why: read off the depth bound rather than hardcoded. The deepest directory has to - // sit exactly at it, so its children are the first thing a walk that failed to stop - // would reject on depth — one level shallower and the mutant walks them silently. - const segments = Array.from( - { length: MAXIMUM_PLUGIN_SCAN_DEPTH }, - (_, index) => `level-${index}` - ) - await Promise.all( - ['a', 'b'].map((name) => mkdir(join(root, ...segments, name), { recursive: true })) - ) - - // Budget: one dirent per level down to the deepest directory, plus the first of its - // two children — so the count is crossed on the second, with the first already read. - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), { - maximumEntries: segments.length + 1 - }) - - // Why: the entries already read before the bound must not still be descended. A scan - // that keeps walking reports the leftovers as depth-truncated, which is a coverage - // failure the walk never observed — exactly the kind of unaccountable claim #10918 was. - expect(result.candidates).toEqual([]) - expect(result.issues).toEqual([{ path: root, reason: 'entry-limit', errorCode: null }]) - }) - - // Why: a declared root costs a resolve before it can be rejected, and a root that does - // not exist reads no dirent at all — so the dirent guard never sees a manifest that - // spends the whole scan on missing paths. Only the resolve guard bounds that, and only - // this shape lets its threshold be read off the entry count instead of assumed. - async function createManifestWithMissingSkillRoots(): Promise<{ - root: string - candidate: string - }> { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-entry-limit-declared-')) - temporaryDirectories.push(root) - // Why: the manifest sits under a vendor directory, not at the scan root, so the - // directory whose declared roots cross the budget is not the root the issue names. - const packageRoot = join(root, 'vendor') - const candidate = join(packageRoot, 'a-skills', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(candidate, { recursive: true }) - await writeFile( - join(packageRoot, '.codex-plugin', 'plugin.json'), - '{"skills":["./a-skills","./missing-one","./missing-two"]}\n' - ) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - return { root, candidate } - } - - // Why: the scan reads exactly eight entries here — the root's dirent, the vendor's two, - // a-skills' and its skill's, then one resolve per declared root. Both budgets below are - // stated against that count so each guard's threshold is asserted, not just its firing. - const DECLARED_ROOT_SCAN_ENTRIES = 8 - - it('stops at the entry budget while resolving declared skill roots', async () => { - const { root, candidate } = await createManifestWithMissingSkillRoots() - - // Why: one short of the full count, so only the last declared root's resolve crosses - // it. A guard that admitted that root would run the scan to completion and report - // nothing, which is what makes the issue below an assertion on the threshold. - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), { - maximumEntries: DECLARED_ROOT_SCAN_ENTRIES - 1 - }) - - // Why: the declared root that exists was fully walked, so the bound is being crossed by - // a resolve of the roots after it — not by the dirent loop stopping the scan early. - expect(result.candidates).toEqual([{ name: 'orca-cli', path: candidate }]) - expect(result.issues).toEqual([{ path: root, reason: 'entry-limit', errorCode: null }]) - }) - - it('resolves the last declared skill root when the entry budget is exactly spent', async () => { - const { root, candidate } = await createManifestWithMissingSkillRoots() - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), { - maximumEntries: DECLARED_ROOT_SCAN_ENTRIES - }) - - // Why: a budget the scan fits inside is not a truncation. Firing one entry early would - // pin the same unclearable attention #10918 did, on a scan that missed nothing. - expect(result.candidates).toEqual([{ name: 'orca-cli', path: candidate }]) - expect(result.issues).toEqual([]) - }) - - it('completes a real-shaped Codex cache without reporting coverage issues', async () => { - // Mirrors ~/.codex/plugins/cache: ///.codex-plugin, with the - // skill's own payload nesting well past the raw traversal depth (issue #10659). - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-real-shape-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'openai-bundled', 'sites', '0.1.31') - const skill = join(packageRoot, 'skills', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir( - join(skill, 'templates', 'vinext-starter', 'examples', 'd1', 'app', 'api', 'deep'), - { recursive: true } - ) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills/"}\n') - await writeFile(join(skill, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ candidates: [{ name: 'orca-cli', path: skill }], issues: [] }) - }) - - it('does not emit a plugin directory that only shares a skill name', async () => { - // The cached plugin is itself called orca-cli. Only the SKILL.md below it is a skill. - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-name-collision-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'openai-bundled', 'orca-cli', '1.0.0') - const skill = join(packageRoot, 'skills', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(skill, { recursive: true }) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills/"}\n') - await writeFile(join(skill, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ candidates: [{ name: 'orca-cli', path: skill }], issues: [] }) - }) - - it('does not emit a bare known-name directory that carries no SKILL.md', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-bare-name-')) - temporaryDirectories.push(root) - await mkdir(join(root, 'vendor', 'orca-cli', 'assets'), { recursive: true }) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ candidates: [], issues: [] }) - }) - - it('stops descending once a skill package payload exceeds the nested skill budget', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-payload-prune-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const skill = join(packageRoot, 'skills', 'sites-building') - const buried = join(skill, 'templates', 'starter', 'examples', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(buried, { recursive: true }) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n') - await writeFile(join(skill, 'SKILL.md'), '# Sites building\n') - await writeFile(join(buried, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - // Why: pruning payload is a topology decision, so it must stay silent rather than - // surface as a coverage issue the user is asked to act on. - expect(result).toEqual({ candidates: [], issues: [] }) - }) - - // Why this exists: the bound below is a deliberate tradeoff, and only the miss side of - // it was covered. Descending further would spend the entry budget on vendor payload — - // the cost that caused the false-attention root collapse in #10865 — while missing a - // copy only ever costs a Details row, because a plugin-cache placement is not - // convergeable by any update command. So the failure direction is silence, which is the - // safe one. Pinning BOTH sides means raising or lowering the bound has to be deliberate - // rather than an accident of refactoring. See #11454. - it.each([ - [0, true], - [1, true], - [2, true], - [3, false], - [4, false] - ])( - 'finds a nested skill %i level(s) below a package: %s', - async (intermediateDepth, expectFound) => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-nested-depth-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const hostSkill = join(packageRoot, 'skills', 'host-skill') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(hostSkill, { recursive: true }) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n') - await writeFile(join(hostSkill, 'SKILL.md'), '# Host skill\n') - - let parent = hostSkill - for (let level = 1; level <= intermediateDepth; level += 1) { - parent = join(parent, `nested-${level}`) - } - const candidate = join(parent, 'orca-cli') - await mkdir(candidate, { recursive: true }) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result.candidates).toEqual(expectFound ? [{ name: 'orca-cli', path: candidate }] : []) - // Why: pruned payload must never surface as a coverage issue either way — that is - // what keeps an ordinary large plugin cache from reporting a permanent problem. - expect(result.issues).toEqual([]) - } - ) - - it('reports a depth-truncated subtree as scan coverage instead of a skill candidate', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-depth-')) - temporaryDirectories.push(root) - const segments = Array.from({ length: 11 }, (_, index) => `level-${index}`) - const hiddenSkill = join(root, ...segments, 'orca-cli') - await mkdir(hiddenSkill, { recursive: true }) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result.candidates).toEqual([]) - expect(result.issues).toHaveLength(1) - expect(result.issues[0]).toMatchObject({ reason: 'depth-limit', errorCode: null }) - expect(hiddenSkill.startsWith(result.issues[0]?.path ?? '')).toBe(true) - }) - - it('does not scan dependency packages for plugin skill entrypoints', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-dependencies-')) - temporaryDirectories.push(root) - await mkdir( - join( - root, - 'vendor', - 'plugin', - 'scripts', - 'node_modules', - ...Array.from({ length: 12 }, (_, index) => `level-${index}`) - ), - { recursive: true } - ) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ candidates: [], issues: [] }) - }) - - it('scans only declared Codex plugin skill roots', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-roots-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const candidate = join(packageRoot, 'custom-skills', 'group', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(candidate, { recursive: true }) - await mkdir( - join(packageRoot, 'payload', ...Array.from({ length: 12 }, (_, index) => `level-${index}`)), - { recursive: true } - ) - await writeFile( - join(packageRoot, '.codex-plugin', 'plugin.json'), - '{"skills":["./custom-skills","./skills"]}\n' - ) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: candidate }], - issues: [] - }) - }) - - it('uses the default skills root for compatible manifests without a skills field', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-default-root-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const candidate = join(packageRoot, 'skills', 'nested', 'orca-cli') - await mkdir(join(packageRoot, '.claude-plugin'), { recursive: true }) - await mkdir(candidate, { recursive: true }) - await writeFile(join(packageRoot, '.claude-plugin', 'plugin.json'), '{"name":"plugin"}\n') - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: candidate }], - issues: [] - }) - }) - - it('falls back to traversal when a manifest skills path is invalid', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-invalid-declared-root-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const candidate = join(packageRoot, 'custom-skills', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(candidate, { recursive: true }) - await writeFile( - join(packageRoot, '.codex-plugin', 'plugin.json'), - '{"skills":"custom-skills"}\n' - ) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: candidate }], - issues: [] - }) - }) - - it('does not traverse plugin payload when the default skills root is missing', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-missing-default-root-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir( - join(packageRoot, 'commands', ...Array.from({ length: 11 }, (_, index) => `level-${index}`)), - { recursive: true } - ) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"name":"plugin"}\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ candidates: [], issues: [] }) - }) - - it('does not traverse plugin payload when the manifest declares no skill roots', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-empty-skill-roots-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir( - join(packageRoot, 'commands', ...Array.from({ length: 11 }, (_, index) => `level-${index}`)), - { recursive: true } - ) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":[]}\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ candidates: [], issues: [] }) - }) - - it('discovers nested skill packages recursively within declared roots', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-boundary-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const skillRoot = join(packageRoot, 'skills', 'sites-building') - await mkdir(join(packageRoot, '.claude-plugin'), { recursive: true }) - await mkdir(join(skillRoot, 'templates', 'orca-cli'), { recursive: true }) - await writeFile(join(packageRoot, '.claude-plugin', 'plugin.json'), '{"skills":"./skills"}\n') - await writeFile(join(skillRoot, 'SKILL.md'), '# Sites building\n') - await writeFile(join(skillRoot, 'templates', 'orca-cli', 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: join(skillRoot, 'templates', 'orca-cli') }], - issues: [] - }) - }) - - it('rejects Windows parent traversal without hiding the default skills root', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-windows-parent-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const candidate = join(packageRoot, 'skills', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(candidate, { recursive: true }) - await writeFile( - join(packageRoot, '.codex-plugin', 'plugin.json'), - '{"skills":"./..\\\\outside"}\n' - ) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: candidate }], - issues: [] - }) - }) - - it('falls through empty manifest directories to the first manifest file', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-precedence-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const candidate = join(packageRoot, 'custom-skills', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(join(packageRoot, '.claude-plugin'), { recursive: true }) - await mkdir(candidate, { recursive: true }) - await mkdir( - join(packageRoot, 'payload', ...Array.from({ length: 12 }, (_, index) => `level-${index}`)), - { recursive: true } - ) - await writeFile( - join(packageRoot, '.claude-plugin', 'plugin.json'), - '{"skills":"./custom-skills"}\n' - ) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: candidate }], - issues: [] - }) - }) - - it('bounds how many skill roots one manifest can declare', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-budget-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const manifestPath = join(packageRoot, '.codex-plugin', 'plugin.json') - const candidate = join(packageRoot, 'r0000', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(candidate, { recursive: true }) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - await writeFile( - manifestPath, - JSON.stringify({ - skills: Array.from({ length: 4096 }, (_, index) => `./r${String(index).padStart(4, '0')}`) - }) - ) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - // Why: resolving declared roots bypasses the entry budget, so without this cap the - // manifest alone decides how long the scan runs. Falling back to the bounded walk - // still finds the skill, so the cap costs coverage nothing. - expect(result.candidates).toEqual([{ name: 'orca-cli', path: candidate }]) - expect(result.issues).toEqual([ - { path: manifestPath, reason: 'manifest-limit', errorCode: null } - ]) - }) - - it('keeps valid roots when a skills array contains an invalid value', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-invalid-root-array-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const defaultCandidate = join(packageRoot, 'skills', 'orca-cli') - const declaredCandidate = join(packageRoot, 'custom-skills', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(defaultCandidate, { recursive: true }) - await mkdir(declaredCandidate, { recursive: true }) - await writeFile( - join(packageRoot, '.codex-plugin', 'plugin.json'), - '{"skills":["./custom-skills",7]}\n' - ) - await writeFile(join(defaultCandidate, 'SKILL.md'), '# Wrong root\n') - await writeFile(join(declaredCandidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: declaredCandidate }], - issues: [] - }) - }) - - it('does not reset the depth budget across nested plugin manifests', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-nested-manifests-')) - temporaryDirectories.push(root) - let pluginRoot = join(root, 'vendor', 'plugin', '1.0.0') - for (let index = 0; index < 8; index += 1) { - await mkdir(join(pluginRoot, '.codex-plugin'), { recursive: true }) - await writeFile(join(pluginRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n') - pluginRoot = join(pluginRoot, 'skills', `nested-${index}`) - } - const hiddenCandidate = join(pluginRoot, 'orca-cli') - await mkdir(hiddenCandidate, { recursive: true }) - await writeFile(join(hiddenCandidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result.candidates).toEqual([]) - expect(result.issues).toContainEqual( - expect.objectContaining({ reason: 'depth-limit', errorCode: null }) - ) - }) - - it('ignores non-directory manifest markers when selecting precedence', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-marker-file-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin', '1.0.0') - const candidate = join(packageRoot, 'custom-skills', 'orca-cli') - await mkdir(packageRoot, { recursive: true }) - await mkdir(join(packageRoot, '.claude-plugin'), { recursive: true }) - await mkdir(candidate, { recursive: true }) - await mkdir( - join(packageRoot, 'payload', ...Array.from({ length: 12 }, (_, index) => `level-${index}`)), - { recursive: true } - ) - await writeFile(join(packageRoot, '.codex-plugin'), 'not a directory\n') - await writeFile( - join(packageRoot, '.claude-plugin', 'plugin.json'), - '{"skills":"./custom-skills"}\n' - ) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: candidate }], - issues: [] - }) - }) - - it('reports manifests that exceed the bounded read limit', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-large-manifest-')) - temporaryDirectories.push(root) - const manifestPath = join(root, 'vendor', 'plugin', '.codex-plugin', 'plugin.json') - await mkdir(join(root, 'vendor', 'plugin', '.codex-plugin'), { recursive: true }) - await writeFile(manifestPath, ' '.repeat(256 * 1024 + 1)) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result.issues).toContainEqual({ - path: manifestPath, - reason: 'manifest-limit', - errorCode: null - }) - }) - - // Why (this and every other skipIf below): creating a symlink on Windows needs - // elevation or Developer Mode, so these would fail EPERM in setup rather than - // exercise the behavior under test. The non-symlink cases still run there. - it.skipIf(process.platform === 'win32')( - 'reports a symlink target that cannot be inspected', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-symlink-error-')) - temporaryDirectories.push(root) - const linkPath = join(root, 'loop') - await symlink('loop', linkPath, 'dir') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result.issues).toContainEqual({ - path: linkPath, - reason: 'io-error', - errorCode: 'ELOOP' - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'preserves read failures when the scan issue limit is reached', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-issue-limit-')) - temporaryDirectories.push(root) - await Promise.all( - Array.from({ length: 16 }, async (_, index) => { - const name = `loop-${index.toString().padStart(2, '0')}` - await symlink(name, join(root, name), 'dir') - }) - ) - const packageRoot = join(root, 'zz-package') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await symlink('SKILL.md', join(packageRoot, 'SKILL.md'), 'file') - await symlink('plugin.json', join(packageRoot, '.codex-plugin', 'plugin.json'), 'file') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result.issues).toContainEqual({ - path: join(root, 'loop-00'), - reason: 'io-error', - errorCode: 'ELOOP' - }) - expect(result.issues).toContainEqual({ - path: root, - reason: 'issue-limit', - errorCode: null - }) - expect(result.issues.filter((issue) => issue.reason === 'issue-limit')).toHaveLength(1) - } - ) - - // Why: linking skills out of the cache is ordinary vendor packaging, so 'outside-root' is - // what fills the display budget on a real install — before any read failure is reached. - async function createCacheBehindSpentBudget(prefix: string, loopCount: number): Promise { - const parent = await mkdtemp(join(tmpdir(), prefix)) - temporaryDirectories.push(parent) - const root = join(parent, 'cache') - const outside = join(parent, 'outside') - await mkdir(outside, { recursive: true }) - await mkdir(root, { recursive: true }) - await Promise.all( - Array.from({ length: MAXIMUM_PLUGIN_SCAN_ISSUES }, (_, index) => - symlink(outside, join(root, `aa-linked-${index.toString().padStart(2, '0')}`), 'dir') - ) - ) - // Sorts after the links, so these are read once the budget is already spent. - await Promise.all( - Array.from({ length: loopCount }, (_, index) => { - const name = `zz-loop-${index.toString().padStart(2, '0')}` - return symlink(name, join(root, name), 'dir') - }) - ) - return root - } - - it.skipIf(process.platform === 'win32')( - 'keeps a read failure that lands after the display budget is spent', - async () => { - const root = await createCacheBehindSpentBudget('orca-plugin-attention-eviction-', 1) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - // Why: a read failure is the only issue that can take the headline off "all up to - // date". Evicting it for display budget reports all-clear over a path that could be - // hiding a stale copy — the bounds that filled the budget say nothing about it. - expect(result.issues).toContainEqual({ - path: join(root, 'zz-loop-00'), - reason: 'io-error', - errorCode: 'ELOOP' - }) - const inventoryIssues = result.issues.map((issue) => ({ - rootId: 'plugin-cache', - sourceLabel: 'Plugin cache', - ...issue - })) - expect(inventoryIssues.some(isSkillScanIssueNeedingAttention)).toBe(true) - } - ) - - it.skipIf(process.platform === 'win32')( - 'bounds how many read failures outrank the display budget', - async () => { - const root = await createCacheBehindSpentBudget( - 'orca-plugin-attention-bound-', - MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES + 4 - ) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - // Why: outranking the budget is what makes this class unbounded, so a tree full of - // unreadable folders must not be able to pin one issue per folder in memory. - expect(result.issues.filter((issue) => issue.reason === 'io-error')).toHaveLength( - MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES - ) - expect(result.issues).toContainEqual({ path: root, reason: 'issue-limit', errorCode: null }) - } - ) - - it('reports the bound that ended the walk even with the issue budget spent', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-truncating-issue-')) - temporaryDirectories.push(root) - await Promise.all( - Array.from({ length: 16 }, (_, index) => - mkdir( - join( - root, - `deep-${index.toString().padStart(2, '0')}`, - ...Array.from({ length: 11 }, (_, level) => `level-${level}`) - ), - { recursive: true } - ) - ) - ) - await Promise.all( - ['zz-one', 'zz-two'].map(async (vendor) => { - await mkdir(join(root, vendor, 'orca-cli'), { recursive: true }) - await writeFile(join(root, vendor, 'orca-cli', 'SKILL.md'), '# Orca CLI\n') - }) - ) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), { - maximumCandidates: 1 - }) - - // Why: the deep trees above exist to spend the display budget, so assert it is full — - // otherwise this passes with budget to spare and stops covering the case it is named - // for. Not 'issue-limit': that only appears when a non-required issue is dropped, and - // the truncating bound below bypasses the budget instead of being dropped by it. - expect(result.issues.filter((issue) => issue.reason === 'depth-limit')).toHaveLength( - MAXIMUM_PLUGIN_SCAN_ISSUES - ) - // Why: losing this one to the display budget is what lets a scan that stopped early - // report all-clear — the bounds that merely skipped a folder say nothing about it. - expect(result.issues).toContainEqual({ path: root, reason: 'candidate-limit', errorCode: null }) - }) - - it('keeps scanning past the issue budget', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-issue-budget-coverage-')) - temporaryDirectories.push(root) - const candidate = join(root, 'zz-package', 'skills', 'orca-cli') - await Promise.all( - Array.from({ length: 20 }, (_, index) => - mkdir( - join( - root, - `deep-${index.toString().padStart(2, '0')}`, - ...Array.from({ length: 11 }, (_, level) => `level-${level}`) - ), - { recursive: true } - ) - ) - ) - await mkdir(candidate, { recursive: true }) - await writeFile(join(candidate, 'SKILL.md'), '# Orca CLI\n') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - // Why: the issue budget bounds what the dialog lists, not how far the walk reaches. - // Ending the scan there would drop real copies and still report all-clear, because - // none of the bounds that filled the budget raise attention. - expect(result.candidates).toEqual([{ name: 'orca-cli', path: candidate }]) - expect(result.issues).toContainEqual({ path: root, reason: 'issue-limit', errorCode: null }) - }) - - it.skipIf(process.platform === 'win32')( - 'still names a fail-closed candidate once the issue budget is spent', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-issue-budget-candidate-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'zz-package') - const candidate = join(packageRoot, 'skills', 'orca-cli') - await Promise.all( - Array.from({ length: 16 }, (_, index) => - mkdir( - join( - root, - `deep-${index.toString().padStart(2, '0')}`, - ...Array.from({ length: 11 }, (_, level) => `level-${level}`) - ), - { recursive: true } - ) - ) - ) - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(join(packageRoot, 'skills'), { recursive: true }) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n') - await symlink('missing-target', candidate, 'dir') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - // Why: the depth bounds fill the issue budget first. Dropping this one for budget - // would leave the badge amber over a candidate the dialog never mentions. - expect(result.candidates).toEqual([{ name: 'orca-cli', path: candidate }]) - expect(result.issues).toContainEqual({ - path: candidate, - reason: 'io-error', - errorCode: 'ENOENT' - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'names the path when a dangling known-name symlink is kept as a candidate', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-declared-dangling-symlink-')) - temporaryDirectories.push(root) - const packageRoot = join(root, 'vendor', 'plugin') - const candidate = join(packageRoot, 'skills', 'orca-cli') - await mkdir(join(packageRoot, '.codex-plugin'), { recursive: true }) - await mkdir(join(packageRoot, 'skills'), { recursive: true }) - await writeFile(join(packageRoot, '.codex-plugin', 'plugin.json'), '{"skills":"./skills"}\n') - await symlink('missing-target', candidate, 'dir') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - // Why: this candidate resolves to nothing, so it reads as inaccessible and raises - // attention. Without the issue the dialog would report all-clear against a badge - // that says otherwise, and nothing would ever name the broken link. - expect(result).toEqual({ - candidates: [{ name: 'orca-cli', path: candidate }], - issues: [{ path: candidate, reason: 'io-error', errorCode: 'ENOENT' }] - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'does not follow directory symlinks outside the plugin cache', - async () => { - const parent = await mkdtemp(join(tmpdir(), 'orca-plugin-symlink-outside-')) - temporaryDirectories.push(parent) - const root = join(parent, 'cache') - const outside = join(parent, 'outside') - const linkPath = join(root, 'vendor') - await mkdir(join(outside, 'orca-cli'), { recursive: true }) - await mkdir(root, { recursive: true }) - await writeFile(join(outside, 'orca-cli', 'SKILL.md'), '# Orca CLI\n') - await symlink(outside, linkPath, 'dir') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [], - issues: [{ path: linkPath, reason: 'outside-root', errorCode: null }] - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'does not follow a SKILL.md symlink outside the plugin cache', - async () => { - const parent = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-file-outside-')) - temporaryDirectories.push(parent) - const root = join(parent, 'cache') - const skill = join(root, 'vendor', 'orca-cli') - const outsideSkillFile = join(parent, 'outside', 'SKILL.md') - await mkdir(skill, { recursive: true }) - await mkdir(join(parent, 'outside'), { recursive: true }) - await writeFile(outsideSkillFile, '# Orca CLI\n') - await symlink(outsideSkillFile, join(skill, 'SKILL.md'), 'file') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [], - issues: [{ path: join(skill, 'SKILL.md'), reason: 'outside-root', errorCode: null }] - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'does not read manifest symlinks outside the plugin cache', - async () => { - const parent = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-outside-')) - temporaryDirectories.push(parent) - const root = join(parent, 'cache') - const outsideManifest = join(parent, 'plugin.json') - const manifestPath = join(root, '.codex-plugin', 'plugin.json') - await mkdir(join(root, '.codex-plugin'), { recursive: true }) - await writeFile(outsideManifest, '{"skills":"./outside"}\n') - await symlink(outsideManifest, manifestPath, 'file') - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ - candidates: [], - issues: [{ path: manifestPath, reason: 'outside-root', errorCode: null }] - }) - } - ) - - it.skipIf(process.platform === 'win32')( - 'does not block on a manifest FIFO', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-plugin-manifest-fifo-')) - temporaryDirectories.push(root) - const manifestPath = join(root, '.codex-plugin', 'plugin.json') - await mkdir(join(root, '.codex-plugin'), { recursive: true }) - await execFileAsync('mkfifo', [manifestPath]) - - const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) - - expect(result).toEqual({ candidates: [], issues: [] }) - }, - 1_000 - ) -}) diff --git a/src/main/skills/skill-plugin-cache-scan.ts b/src/main/skills/skill-plugin-cache-scan.ts deleted file mode 100644 index 029b5dd6f3c..00000000000 --- a/src/main/skills/skill-plugin-cache-scan.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type { Dirent } from 'node:fs' -import { opendir, realpath, stat } from 'node:fs/promises' -import { basename, join } from 'node:path' -import { - isSkillScanAttentionReason, - isTruncatingSkillScanReason, - type SkillFreshnessScanIssueReason -} from '../../shared/skill-freshness' -import { declaredPluginSkillRoots, isWithinRoot } from './skill-plugin-manifest-roots' - -export const MAXIMUM_PLUGIN_SCAN_DEPTH = 9 -const MAXIMUM_DECLARED_SKILL_SCAN_DEPTH = 6 -// Why: a skill package's own payload (templates, fixtures, sample apps) is not a skill -// tree, and it is what drives ordinary caches past the depth and entry bounds. Descend -// far enough to still find a skill grouped under a package, then stop. -// -// Changing this is a real tradeoff, not a tuning knob. Raising it spends the entry budget -// on vendor payload — the cost that made ordinary caches collapse to a poison sentinel and -// pin every skill amber (#10865). Lowering it, or leaving it, means a skill buried deeper -// is never seen; that costs only a Details row, because a plugin-cache placement is not -// convergeable by any update command. So the failure direction here is silence, which is -// the safe one. Both sides of the boundary are pinned by test (#11454) — if you move this, -// that test will fail, and it is meant to. -const MAXIMUM_NESTED_SKILL_DEPTH = 2 -// Why: sized against a real multi-vendor cache, which reads ~7k entries once payload is -// pruned. The bound still exists to stop a hostile or runaway tree; it is not a budget -// ordinary installs are meant to exhaust. -export const MAXIMUM_PLUGIN_SCAN_ENTRIES = 16_384 -export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64 -export const MAXIMUM_PLUGIN_SCAN_ISSUES = 16 -// Why: an attention issue outranks the display budget, so nothing else bounds how many a -// pathological tree can pin in memory. One is all the badge needs to be truthful; a few -// more give the dialog enough distinct paths to read as a pattern, and 'issue-limit' still -// says there are others. -export const MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES = 4 -const SKILL_FILE_NAME = 'SKILL.md' - -export type KnownPluginSkillCandidate = { - name: string - path: string -} - -export type KnownPluginSkillScanIssue = { - path: string - reason: SkillFreshnessScanIssueReason - errorCode: string | null -} - -export type KnownPluginSkillScan = { - candidates: KnownPluginSkillCandidate[] - issues: KnownPluginSkillScanIssue[] -} - -function errorCode(error: unknown): string | null { - return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' - ? error.code - : null -} - -// Why: overriding a bound is how its truncation path stays executable — reaching the real -// entry budget costs a 16k-dirent fixture per case, and the declared-root guard below it -// needs the running count parked just under that budget. Production passes neither. -export type PluginSkillScanBounds = { - maximumCandidates?: number - maximumEntries?: number -} - -export async function scanKnownPluginSkillCandidates( - rootPath: string, - knownNames: ReadonlySet, - bounds: PluginSkillScanBounds = {} -): Promise { - const maximumCandidates = bounds.maximumCandidates ?? MAXIMUM_PLUGIN_SKILL_CANDIDATES - const maximumEntries = bounds.maximumEntries ?? MAXIMUM_PLUGIN_SCAN_ENTRIES - const candidates: KnownPluginSkillCandidate[] = [] - const issues: KnownPluginSkillScanIssue[] = [] - const issueKeys = new Set() - const visited = new Set() - let attentionIssueCount = 0 - let resolvedRoot: string | null = null - let entryCount = 0 - let limitReached = false - - // Why: an issue that explains a candidate is not optional. Dropping it for budget - // leaves the badge reacting to a placement the dialog can't account for, which is - // the split this change exists to remove — so those are charged past the bound, - // itself bounded by the candidate cap. - function recordIssue( - path: string, - reason: KnownPluginSkillScanIssue['reason'], - code: string | null = null, - explainsCandidate = false - ): void { - const key = `${path}\0${reason}\0${code ?? ''}` - if (issueKeys.has(key)) { - return - } - // Why: an attention issue is the only thing that can turn the headline off "all up to - // date", so evicting one for display budget makes Orca report all-clear over a read - // failure. Reserving a few keeps that unbounded on a tree full of unreadable folders. - const attention = - isSkillScanAttentionReason(reason) && - attentionIssueCount < MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES - // Why: the bound that ended the walk is the one issue the dialog cannot do without - // — dropping it for display budget is what lets a truncated scan report all-clear. - const required = explainsCandidate || attention || isTruncatingSkillScanReason(reason) - // Why: this budget bounds what the dialog lists, not how far the scan reaches. - // Ending the walk here would truncate coverage over a display limit — and since - // Orca's own bounds no longer raise attention, it would do so silently. - if (!required && issues.length >= MAXIMUM_PLUGIN_SCAN_ISSUES) { - if (!issues.some((issue) => issue.reason === 'issue-limit')) { - issues.push({ - path: rootPath, - reason: 'issue-limit', - errorCode: null - }) - } - return - } - issueKeys.add(key) - if (isSkillScanAttentionReason(reason)) { - attentionIssueCount += 1 - } - issues.push({ path, reason, errorCode: code }) - } - - function recordCandidate(name: string, path: string): void { - if (candidates.length >= maximumCandidates) { - limitReached = true - recordIssue(rootPath, 'candidate-limit') - return - } - candidates.push({ name, path }) - } - - // Why: a directory only proves it is a skill by carrying SKILL.md. Matching a known - // name alone would promote any same-named plugin or vendor folder into an installation - // Orca never verified. - async function hasSkillFile( - directory: string, - entries: readonly Dirent[], - resolvedDirectory: string - ): Promise { - const skillFile = entries.find((entry) => entry.name === SKILL_FILE_NAME) - if (!skillFile) { - return false - } - if (!skillFile.isSymbolicLink()) { - return skillFile.isFile() - } - try { - const skillFilePath = join(directory, skillFile.name) - const resolvedSkillFile = await realpath(skillFilePath) - if (!isWithinRoot(resolvedRoot ?? resolvedDirectory, resolvedSkillFile)) { - recordIssue(skillFilePath, 'outside-root') - return false - } - return (await stat(resolvedSkillFile)).isFile() - } catch (error) { - if (errorCode(error) !== 'ENOENT') { - recordIssue(join(directory, skillFile.name), 'io-error', errorCode(error)) - } - return false - } - } - - async function visit( - directory: string, - depth: number, - withinDeclaredSkillRoot = false, - payloadDepth: number | null = null - ): Promise { - if (limitReached) { - return - } - const maximumDepth = withinDeclaredSkillRoot - ? MAXIMUM_DECLARED_SKILL_SCAN_DEPTH - : MAXIMUM_PLUGIN_SCAN_DEPTH - if (depth > maximumDepth) { - recordIssue(directory, 'depth-limit') - return - } - let resolved: string - try { - resolved = await realpath(directory) - } catch (error) { - if (errorCode(error) !== 'ENOENT') { - recordIssue(directory, 'io-error', errorCode(error)) - } - return - } - if (resolvedRoot === null) { - resolvedRoot = resolved - } else if (!isWithinRoot(resolvedRoot, resolved)) { - recordIssue(directory, 'outside-root') - return - } - if (visited.has(resolved)) { - return - } - visited.add(resolved) - - let handle: Awaited> - try { - handle = await opendir(resolved) - } catch (error) { - recordIssue(directory, 'io-error', errorCode(error)) - return - } - const entries: Dirent[] = [] - try { - for (;;) { - const entry = await handle.read() - if (!entry) { - break - } - entryCount += 1 - if (entryCount > maximumEntries) { - limitReached = true - recordIssue(rootPath, 'entry-limit') - break - } - entries.push(entry) - } - } catch (error) { - recordIssue(directory, 'io-error', errorCode(error)) - } finally { - await handle.close().catch(() => undefined) - } - - const isSkillPackage = await hasSkillFile(directory, entries, resolved) - if (isSkillPackage) { - const name = basename(directory) - if (knownNames.has(name)) { - recordCandidate(name, directory) - } - } - - // Why: pruning payload is a topology decision, not a coverage failure, so it stays - // silent — recording it would put Orca's own traversal rules in the user's dialog. - const nextPayloadDepth = isSkillPackage ? 0 : payloadDepth === null ? null : payloadDepth + 1 - if (nextPayloadDepth !== null && nextPayloadDepth > MAXIMUM_NESTED_SKILL_DEPTH) { - return - } - - const skillRoots = await declaredPluginSkillRoots(directory, entries, resolvedRoot, recordIssue) - if (limitReached) { - return - } - if (skillRoots) { - const skillRootDepth = withinDeclaredSkillRoot ? depth + 1 : 0 - for (const skillRoot of skillRoots.sort()) { - entryCount += 1 - if (entryCount > maximumEntries) { - limitReached = true - recordIssue(rootPath, 'entry-limit') - return - } - await visit(skillRoot, skillRootDepth, true) - } - return - } - - entries.sort((left, right) => (left.name === right.name ? 0 : left.name < right.name ? -1 : 1)) - for (const entry of entries) { - if (limitReached) { - return - } - if (entry.name === 'node_modules') { - continue - } - const entryPath = join(directory, entry.name) - let directoryEntry = entry.isDirectory() - if (entry.isSymbolicLink()) { - try { - directoryEntry = (await stat(entryPath)).isDirectory() - if (directoryEntry) { - const resolvedEntry = await realpath(entryPath) - if (resolvedRoot !== null && !isWithinRoot(resolvedRoot, resolvedEntry)) { - recordIssue(entryPath, 'outside-root') - continue - } - } - } catch (error) { - const code = errorCode(error) - // Why: inside a declared skill root the plugin itself claims this name is a - // skill, so an uninspectable link stays fail-closed. Outside one there is no - // such claim and no SKILL.md to read, so inventing a copy would be a guess. - const claimedSkill = withinDeclaredSkillRoot && knownNames.has(entry.name) - // Why: a fail-closed candidate reads as inaccessible and raises attention, so - // the path has to be named even when it is merely absent. - if (claimedSkill) { - recordIssue(entryPath, 'io-error', code, true) - recordCandidate(entry.name, entryPath) - } else if (code !== 'ENOENT') { - recordIssue(entryPath, 'io-error', code) - } - continue - } - } - if (!directoryEntry) { - continue - } - await visit(entryPath, depth + 1, withinDeclaredSkillRoot, nextPayloadDepth) - } - } - - await visit(rootPath, 0) - return { candidates, issues } -} diff --git a/src/main/skills/skill-plugin-manifest-roots.ts b/src/main/skills/skill-plugin-manifest-roots.ts deleted file mode 100644 index ab2ef726477..00000000000 --- a/src/main/skills/skill-plugin-manifest-roots.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { constants, type Dirent } from 'node:fs' -import { open, realpath } from 'node:fs/promises' -import { isAbsolute, join, relative, sep } from 'node:path' -import type { SkillFreshnessScanIssueReason } from '../../shared/skill-freshness' - -const MAXIMUM_PLUGIN_MANIFEST_BYTES = 256 * 1024 -// Why: every declared root costs a resolve before it can be rejected, and those resolves -// bypass the dirent walk the entry budget bounds — so one manifest could otherwise spend -// the whole scan on paths that don't exist. No real plugin declares this many. -const MAXIMUM_DECLARED_SKILL_ROOTS = 64 -// Why: only formats whose skill layout is known. Treating an unverified manifest as a -// declaration prunes the rest of that plugin, so a wrong guess hides real skills; with -// no manifest the ordinary walk still finds them. -const PLUGIN_MANIFEST_DIRECTORIES = ['.codex-plugin', '.claude-plugin'] as const -const MANIFEST_OPEN_FLAGS = - constants.O_RDONLY | - (process.platform === 'win32' ? 0 : constants.O_NONBLOCK | constants.O_NOFOLLOW) - -function errorCode(error: unknown): string | null { - return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' - ? error.code - : null -} - -export function isWithinRoot(root: string, path: string): boolean { - const relativePath = relative(root, path) - return !isAbsolute(relativePath) && relativePath.split(sep)[0] !== '..' -} - -function resolveManifestSkillPath(directory: string, value: unknown): string | null { - if (typeof value !== 'string' || !value.startsWith('./')) { - return null - } - const relativePath = value.slice(2) - if (!relativePath || relativePath.split(/[\\/]/).includes('..')) { - return null - } - return join(directory, relativePath) -} - -export async function declaredPluginSkillRoots( - directory: string, - entries: readonly Dirent[], - resolvedRoot: string, - recordIssue: (path: string, reason: SkillFreshnessScanIssueReason, code?: string | null) => void -): Promise { - for (const manifestDirectory of PLUGIN_MANIFEST_DIRECTORIES) { - if (!entries.some((entry) => entry.name === manifestDirectory)) { - continue - } - const manifestPath = join(directory, manifestDirectory, 'plugin.json') - let resolvedManifestPath: string - try { - resolvedManifestPath = await realpath(manifestPath) - } catch (error) { - const code = errorCode(error) - if (code === 'ENOENT' || code === 'ENOTDIR') { - continue - } - recordIssue(manifestPath, 'io-error', code) - return null - } - if (!isWithinRoot(resolvedRoot, resolvedManifestPath)) { - recordIssue(manifestPath, 'outside-root') - return null - } - let manifestFile: Awaited> - try { - manifestFile = await open(resolvedManifestPath, MANIFEST_OPEN_FLAGS) - } catch (error) { - const code = errorCode(error) - if (code === 'ENOENT' || code === 'ENOTDIR') { - continue - } - recordIssue(manifestPath, 'io-error', code) - return null - } - try { - const manifestStat = await manifestFile.stat() - if (!manifestStat.isFile()) { - continue - } - if (manifestStat.size > MAXIMUM_PLUGIN_MANIFEST_BYTES) { - recordIssue(manifestPath, 'manifest-limit') - return null - } - const content = Buffer.alloc(MAXIMUM_PLUGIN_MANIFEST_BYTES + 1) - let contentLength = 0 - while (contentLength < content.length) { - const { bytesRead } = await manifestFile.read( - content, - contentLength, - content.length - contentLength, - contentLength - ) - if (bytesRead === 0) { - break - } - contentLength += bytesRead - } - if (contentLength > MAXIMUM_PLUGIN_MANIFEST_BYTES) { - recordIssue(manifestPath, 'manifest-limit') - return null - } - const parsed: unknown = JSON.parse(content.toString('utf8', 0, contentLength)) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null - } - const skills = (parsed as Record).skills - if (skills === undefined) { - return [join(directory, 'skills')] - } - if (Array.isArray(skills) && skills.length === 0) { - return [] - } - const values = Array.isArray(skills) ? skills : [skills] - const roots = [ - ...new Set( - values - .map((value) => resolveManifestSkillPath(directory, value)) - .filter((value): value is string => value !== null) - ) - ].sort() - if (roots.length === 0) { - return null - } - // Why: fall back to the ordinary walk rather than a truncated root list. The walk - // is bounded by depth and entries, so it costs less than resolving the declared - // roots one by one and still reaches skills a truncation would have dropped. - if (roots.length > MAXIMUM_DECLARED_SKILL_ROOTS) { - recordIssue(manifestPath, 'manifest-limit') - return null - } - return roots - } catch (error) { - if (!(error instanceof SyntaxError)) { - recordIssue(manifestPath, 'io-error', errorCode(error)) - } - return null - } finally { - await manifestFile.close().catch(() => undefined) - } - } - return null -} diff --git a/src/main/skills/skill-update-outcome.test.ts b/src/main/skills/skill-update-outcome.test.ts deleted file mode 100644 index 6dbe39767b1..00000000000 --- a/src/main/skills/skill-update-outcome.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - SkillFreshnessInstallation, - SkillFreshnessStatus, - SkillInstallationTopology -} from '../../shared/skill-freshness' -import { skillUpdateFailedNames } from './skill-update-outcome' - -function placement( - name: string, - status: SkillFreshnessStatus, - topology: SkillInstallationTopology = 'canonical-copy' -): SkillFreshnessInstallation { - return { - id: `${name}-${topology}-${status}`, - name, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: `/home/.agents/skills/${name}`, - resolvedPath: `/home/.agents/skills/${name}`, - physicalIdentity: `physical-${name}`, - topology, - status, - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null - } -} - -describe('skillUpdateFailedNames', () => { - it('treats a convergent copy that is now current as landed', () => { - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'current')])).toEqual([]) - }) - - it('reports a copy the run left outdated', () => { - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')])).toEqual([ - 'orca-cli' - ]) - }) - - it('reports an unreadable copy', () => { - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'inaccessible')])).toEqual([ - 'orca-cli' - ]) - }) - - it('reports a skill the run removed outright', () => { - expect(skillUpdateFailedNames(['orca-cli'], [])).toEqual(['orca-cli']) - }) - - it('ignores placements the update command never writes to', () => { - expect( - skillUpdateFailedNames( - ['orca-cli'], - [placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'plugin-cache')] - ) - ).toEqual([]) - }) - - it('fails the name when the run leaves a broken link behind', () => { - expect( - skillUpdateFailedNames( - ['orca-cli'], - [placement('orca-cli', 'current'), placement('orca-cli', 'inaccessible', 'broken-link')] - ) - ).toEqual(['orca-cli']) - }) - - it('fails the name when any convergent alias was left behind', () => { - expect( - skillUpdateFailedNames( - ['orca-cli'], - [placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'provider-alias')] - ) - ).toEqual(['orca-cli']) - }) - - it('judges each requested name independently', () => { - expect( - skillUpdateFailedNames( - ['orca-cli', 'orchestration'], - [placement('orca-cli', 'current'), placement('orchestration', 'outdated')] - ) - ).toEqual(['orchestration']) - }) -}) diff --git a/src/main/skills/skill-update-outcome.ts b/src/main/skills/skill-update-outcome.ts deleted file mode 100644 index 29b288ade04..00000000000 --- a/src/main/skills/skill-update-outcome.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, - type SkillFreshnessInstallation -} from '../../shared/skill-freshness' - -/** - * Names that did not land, judged from the post-run inventory. - * - * Why not "whatever is still in `eligibleUpdateNames`": a name leaves that list for - * reasons that are not success. A failed write can leave the directory unreadable, and - * a removed skill has no convergent placement at all; each would read as "updated" over - * a broken skill. The install is pinned to this build, so every placement it writes has - * to come back current. - * - * A broken link fails the name too: eligibility skips names that had one before the run, - * so one after it is the run's own damage. - */ -export function skillUpdateFailedNames( - names: readonly string[], - installations: readonly SkillFreshnessInstallation[] -): string[] { - return names.filter((name) => { - const placements = installations.filter((entry) => entry.name === name) - const convergent = placements.filter((entry) => - SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) - ) - return ( - convergent.length === 0 || - convergent.some((entry) => entry.status !== 'current') || - placements.some((entry) => entry.topology === 'broken-link') - ) - }) -} diff --git a/src/main/skills/skill-update-run.test.ts b/src/main/skills/skill-update-run.test.ts deleted file mode 100644 index 43b61bdd6fb..00000000000 --- a/src/main/skills/skill-update-run.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' -import type { SkillUpdateRun } from '../../shared/skill-freshness' -import { - getSpawnArgsForWindows, - WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL -} from '../../shared/windows-batch-spawn' -import { CANCEL_RELEASE_TIMEOUT_MS, SkillUpdateRunner } from './skill-update-run' -import { SKILLS_CLI_VERSION } from '../../shared/agent-feature-install-commands' - -class FakeChild extends EventEmitter { - stdout = new EventEmitter() - stderr = new EventEmitter() - pid: number | undefined = 1234 - kill = vi.fn() -} - -function makeRunner( - overrides: { - rescanOutdatedNames?: (names: string[]) => Promise - resolveCommand?: (name: string) => string - killTree?: (pid: number, killRoot: () => void) => Promise - buildSpawnArgs?: (command: string, args: string[]) => { spawnCmd: string; spawnArgs: string[] } - } = {} -) { - const child = new FakeChild() - const spawnCalls: { command: string; args: string[]; options: Record }[] = [] - const states: SkillUpdateRun[] = [] - const runner = new SkillUpdateRunner({ - sourceCommit: '0123456789abcdef0123456789abcdef01234567', - detectAgentKeys: () => ['claude-code', 'universal'], - now: () => 1000, - resolveCommand: overrides.resolveCommand ?? (() => '/usr/local/bin/npx'), - rescanOutdatedNames: overrides.rescanOutdatedNames, - // Default to a no-op sweep so tests never signal a real PID. - killTree: overrides.killTree ?? (async (_pid, killRoot) => killRoot()), - buildSpawnArgs: overrides.buildSpawnArgs, - onState: (run) => states.push(run), - spawnProcess: ((command: string, args: string[], options: Record) => { - spawnCalls.push({ command, args, options }) - return child as never - }) as never - }) - return { runner, child, spawnCalls, states } -} - -async function flush(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('SkillUpdateRunner', () => { - it('reinstalls the sorted names pinned to this release, for the detected agents', () => { - const { runner, spawnCalls } = makeRunner() - - expect(runner.start(['orchestration', 'orca-cli'])).toEqual({ started: true }) - expect(spawnCalls[0].command).toBe('/usr/local/bin/npx') - // `npx --yes` skips the install prompt; `skills -y` takes the CLI's own - // non-interactive branch. Dropping either can wedge the run. - expect(spawnCalls[0].args).toEqual([ - '--yes', - `skills@${SKILLS_CLI_VERSION}`, - 'add', - 'https://github.com/stablyai/orca/tree/0123456789abcdef0123456789abcdef01234567', - '--skill', - 'orca-cli', - '--skill', - 'orchestration', - '--global', - '--agent', - 'claude-code', - '--agent', - 'universal', - '-y' - ]) - }) - - it('ignores stdin so the CLI sees a non-TTY', () => { - const { runner, spawnCalls } = makeRunner() - runner.start(['orca-cli']) - - expect(spawnCalls[0].options.stdio).toEqual(['ignore', 'pipe', 'pipe']) - }) - - it('forces git messages to English so the skills CLI can fall back to fetching the commit', () => { - vi.stubEnv('LC_ALL', 'ko_KR.UTF-8') - try { - const { runner, spawnCalls } = makeRunner() - runner.start(['orca-cli']) - - expect(spawnCalls[0].options.env).toMatchObject({ LC_ALL: 'C' }) - } finally { - vi.unstubAllEnvs() - } - }) - - it('rejects names that could carry shell syntax', () => { - const { runner, spawnCalls } = makeRunner() - - expect(runner.start(['orca-cli; rm -rf /'])).toEqual({ - started: false, - reason: 'invalid-names' - }) - expect(spawnCalls).toHaveLength(0) - }) - - it('refuses a second concurrent run', () => { - const { runner } = makeRunner() - runner.start(['orca-cli']) - - expect(runner.start(['orchestration'])).toEqual({ started: false, reason: 'already-running' }) - }) - - it('strips ANSI colour and carriage returns from captured output', async () => { - const { runner, child } = makeRunner({ rescanOutdatedNames: async () => [] }) - runner.start(['orca-cli']) - child.stdout.emit('data', Buffer.from('\x1b[36mChecking\x1b[0m\rUpdating orca-cli…')) - child.emit('close', 0) - await flush() - - const run = runner.getState() - expect(run.state).toBe('success') - expect(run.state === 'success' && run.output).toBe('Checking\nUpdating orca-cli…') - }) - - it('treats a clean re-scan as success even though the exit code is non-zero', async () => { - // A peer skill outside our request can fail the process; what we asked for landed. - const { runner, child } = makeRunner({ rescanOutdatedNames: async () => [] }) - runner.start(['orca-cli']) - child.emit('close', 1) - await flush() - - expect(runner.getState().state).toBe('success') - }) - - it('attributes failure to the names the re-scan says are still outdated', async () => { - const { runner, child } = makeRunner({ - rescanOutdatedNames: async () => ['orchestration'] - }) - runner.start(['orca-cli', 'orchestration']) - child.emit('close', 1) - await flush() - - const run = runner.getState() - expect(run.state).toBe('error') - expect(run.state === 'error' && run.failedNames).toEqual(['orchestration']) - }) - - it('fails every requested name when the re-scan itself throws', async () => { - const { runner, child } = makeRunner({ - rescanOutdatedNames: async () => { - throw new Error('scan blew up') - } - }) - runner.start(['orca-cli']) - child.emit('error', new Error('spawn ENOENT')) - await flush() - - const run = runner.getState() - expect(run.state).toBe('error') - expect(run.state === 'error' && run.failedNames).toEqual(['orca-cli']) - expect(run.state === 'error' && run.message).toBe('spawn ENOENT') - }) - - it('keeps the spawn error when the failed child also emits close', async () => { - // A spawn failure emits `error` *then* `close`. Without a latch the second - // settle overwrites `spawn ENOENT` with a useless "exited with code null". - const rescan = vi.fn(async () => ['orca-cli']) - const { runner, child } = makeRunner({ rescanOutdatedNames: rescan }) - runner.start(['orca-cli']) - child.emit('error', new Error('spawn ENOENT')) - child.emit('close', null) - await flush() - - const run = runner.getState() - expect(run.state === 'error' && run.message).toBe('spawn ENOENT') - expect(rescan).toHaveBeenCalledTimes(1) - }) - - it('refuses a new run until the cancelled process tree is actually dead', async () => { - // The sweep waits for a descendant snapshot before it signals anything, so - // releasing the UI synchronously would let a second npx write the same - // bundles as the one still being killed. - let finishKill = (): void => {} - const { runner } = makeRunner({ - killTree: (_pid, killRoot) => - new Promise((resolve) => { - finishKill = () => { - killRoot() - resolve() - } - }) - }) - runner.start(['orca-cli']) - runner.cancel() - await flush() - - expect(runner.getState().state).toBe('running') - expect(runner.start(['orchestration'])).toEqual({ started: false, reason: 'already-running' }) - - finishKill() - await flush() - expect(runner.getState()).toEqual({ state: 'idle' }) - expect(runner.start(['orchestration'])).toEqual({ started: true }) - }) - - it('releases the run even if the kill sweep never settles', async () => { - vi.useFakeTimers() - try { - // Stop is already spent by this point, so a sweep that hangs would leave - // the run wedged in `running` with no way out. - const { runner } = makeRunner({ killTree: () => new Promise(() => {}) }) - runner.start(['orca-cli']) - runner.cancel() - const stopping = runner.getState() - expect(stopping.state).toBe('running') - // Surfaced so the dialog can retire the Stop affordance it already spent. - expect(stopping.state === 'running' && stopping.stopping).toBe(true) - - await vi.advanceTimersByTimeAsync(CANCEL_RELEASE_TIMEOUT_MS) - - expect(runner.getState()).toEqual({ state: 'idle' }) - expect(runner.start(['orchestration'])).toEqual({ started: true }) - } finally { - vi.useRealTimers() - } - }) - - it('does not let a cancelled child settle the run that replaced it', async () => { - const children: FakeChild[] = [] - const states: SkillUpdateRun[] = [] - const runner = new SkillUpdateRunner({ - sourceCommit: '0123456789abcdef0123456789abcdef01234567', - detectAgentKeys: () => ['universal'], - now: () => 1000, - resolveCommand: () => '/usr/local/bin/npx', - rescanOutdatedNames: async () => [], - // Never let a test reach the real sweep — it would signal live PIDs. - killTree: async (_pid, killRoot) => killRoot(), - onState: (run) => states.push(run), - spawnProcess: (() => { - const child = new FakeChild() - children.push(child) - return child as never - }) as never - }) - runner.start(['orca-cli']) - runner.cancel() - await flush() - runner.start(['orchestration']) - // The killed child's exit lands after the replacement is already in flight. - children[0].stdout.emit('data', Buffer.from('output from the dead run')) - children[0].emit('close', 1) - await flush() - - const run = runner.getState() - expect(run.state).toBe('running') - expect(run.state === 'running' && run.names).toEqual(['orchestration']) - expect(run.state === 'running' && run.output).toBe('') - expect(states.some((state) => state.state === 'error')).toBe(false) - }) - - it('returns to idle on cancel and stops reporting output', async () => { - const { runner, child, states } = makeRunner({ rescanOutdatedNames: async () => [] }) - runner.start(['orca-cli']) - runner.cancel() - child.stdout.emit('data', Buffer.from('late output')) - await flush() - - expect(child.kill).toHaveBeenCalled() - expect(runner.getState()).toEqual({ state: 'idle' }) - expect(states.at(-1)).toEqual({ state: 'idle' }) - }) - - it('ignores a re-scan that resolves after the run was cancelled', async () => { - let releaseRescan = (): void => {} - const { runner, child } = makeRunner({ - rescanOutdatedNames: () => - new Promise((resolve) => { - releaseRescan = () => resolve([]) - }) - }) - runner.start(['orca-cli']) - child.emit('close', 0) - await flush() - // The re-scan re-hashes every package, so a cancel lands well inside it. - runner.cancel() - releaseRescan() - await flush() - - expect(runner.getState()).toEqual({ state: 'idle' }) - }) - - it('kills the whole npx tree on cancel, not just the wrapper', async () => { - const killTree = vi.fn(async (_pid: number, killRoot: () => void) => { - killRoot() - }) - const { runner, child } = makeRunner({ killTree }) - child.pid = 4242 - runner.start(['orca-cli']) - runner.cancel() - await flush() - - expect(killTree).toHaveBeenCalledWith(4242, expect.any(Function)) - expect(child.kill).toHaveBeenCalled() - }) - - it('surfaces an unspawnable command path instead of silently doing nothing', async () => { - // A Windows profile directory containing `&` makes the cmd.exe rail reject - // the resolved npx path; the names are already canonical by this point. - const { runner, states } = makeRunner({ - resolveCommand: () => 'C:\\Users\\A&B\\AppData\\Roaming\\npm\\npx.cmd', - buildSpawnArgs: () => { - throw new Error('unsafe batch arguments') - } - }) - - const result = runner.start(['orca-cli']) - - expect(result.started).toBe(false) - const run = runner.getState() - expect(run.state).toBe('error') - expect(run.state === 'error' && run.failedNames).toEqual(['orca-cli']) - expect(states.at(-1)?.state).toBe('error') - // Why: the message used to name a character set the guard no longer matches. - expect(run.state === 'error' && run.message).toContain(WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL) - }) - - it('spawns npx from a Program Files (x86) install instead of refusing it', () => { - const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - try { - const npx = 'C:\\Program Files (x86)\\nodejs\\npx.cmd' - const { runner, spawnCalls } = makeRunner({ - resolveCommand: () => npx, - buildSpawnArgs: getSpawnArgsForWindows - }) - - expect(runner.start(['orca-cli']).started).toBe(true) - expect(spawnCalls[0]?.args.slice(0, 3)).toEqual(['/d', '/c', npx]) - } finally { - platform.mockRestore() - } - }) - - it('coalesces progress frames into one push instead of one per chunk', async () => { - const { runner, child, states } = makeRunner({ rescanOutdatedNames: async () => [] }) - runner.start(['orca-cli']) - const pushesAfterStart = states.length - for (let frame = 0; frame < 25; frame += 1) { - child.stdout.emit('data', Buffer.from(`\rfetching ${frame}%`)) - } - expect(states.length).toBe(pushesAfterStart) - - child.emit('close', 0) - await flush() - - const run = runner.getState() - expect(run.state).toBe('success') - // Nothing is dropped — the tail is drained before the run settles. - expect(run.state === 'success' && run.output).toContain('fetching 24%') - }) - - it('acknowledge clears a settled run but leaves a live one alone', async () => { - const { runner, child } = makeRunner({ rescanOutdatedNames: async () => [] }) - runner.start(['orca-cli']) - runner.acknowledge() - expect(runner.getState().state).toBe('running') - - child.emit('close', 0) - await flush() - runner.acknowledge() - expect(runner.getState()).toEqual({ state: 'idle' }) - }) -}) diff --git a/src/main/skills/skill-update-run.ts b/src/main/skills/skill-update-run.ts deleted file mode 100644 index 5c6936042d4..00000000000 --- a/src/main/skills/skill-update-run.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { spawn, type ChildProcess } from 'node:child_process' -import { - canonicalizeSkillUpdateNames, - type SkillUpdateRun, - type SkillUpdateStartResult -} from '../../shared/skill-freshness' -import { - buildAgentFeatureSkillInstallArgs, - SKILLS_CLI_ENV_OVERRIDES -} from '../../shared/agent-feature-install-commands' -import { detectSkillsCliUpdateAgentKeys } from '../../shared/skills-cli-agent-detection' -import { resolveCliCommand } from '../codex-cli/command' -import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import { killWithDescendantSweep } from '../pty-descendant-termination' -import { getSpawnArgsForWindows, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../win32-utils' - -// Why: `skills add` prints ANSI colour and \r + erase-line progress. We show -// this log verbatim to the user but never parse it — `add` has no --json, so -// stdout is not a contract. -const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g // eslint-disable-line no-control-regex - -// Keep the tail: failures land at the end, and an unbounded buffer would pin -// however much the CLI decides to print. -const MAX_OUTPUT_CHARS = 32_000 - -// Long enough to swallow a burst of progress frames, short enough that the log -// still reads as live when the user has it expanded. -const OUTPUT_FLUSH_MS = 100 - -// Strictly above the sweep's own transitive worst case (~1s on POSIX; 3s identity -// query + 5s taskkill on Windows). A backstop that ties its own bound would fire -// while a slow-but-healthy sweep is still working. -export const CANCEL_RELEASE_TIMEOUT_MS = 12_000 - -export type SkillUpdateRunnerDeps = { - /** The commit the running build was made from; the install is pinned to it. */ - sourceCommit: string - /** Same targets `orca skills update` picks on this host. */ - detectAgentKeys?: () => string[] - spawnProcess?: typeof spawn - resolveCommand?: (commandName: string) => string - /** Returns the subset of `names` that did not land, re-read from disk. */ - rescanOutdatedNames?: (names: string[]) => Promise - killTree?: (pid: number, killRoot: () => void) => Promise - /** Injected so the Windows cmd.exe rail is reachable off Windows. */ - buildSpawnArgs?: typeof getSpawnArgsForWindows - now?: () => number - onState?: (run: SkillUpdateRun) => void -} - -function stripAnsi(value: string): string { - return value.replace(ANSI_RE, '').replace(/\r(?!\n)/g, '\n') -} - -function clampOutput(value: string): string { - return value.length <= MAX_OUTPUT_CHARS ? value : value.slice(value.length - MAX_OUTPUT_CHARS) -} - -/** - * Runs the pinned `npx --yes skills@ add /tree/ --skill ... --global - * --agent ... -y` headlessly. `add` always re-copies, so the result is this build's bytes - * however the old copy was installed or edited. - * - * Both `--yes` flags are load-bearing and distinct: `npx --yes` skips the - * install-this-package prompt, and `skills … -y` takes the CLI's own - * non-interactive branch. `skills` gates its prompts on - * `options.yes || !process.stdin.isTTY`, and stdin is ignored below, so the run - * cannot block on input that no one can answer. - */ -export class SkillUpdateRunner { - private run: SkillUpdateRun = { state: 'idle' } - private child: ChildProcess | null = null - // Why: a failed spawn emits `error` *and* `close`, and a cancelled child still - // emits `close` after `kill()`. The token retires a child's handlers so a dead - // run can never settle or write output into the run that replaced it; the latch - // keeps the first verdict of a live run while its re-scan is still in flight. - private runToken = 0 - private settling = false - private killing = false - private readonly deps: Required> & SkillUpdateRunnerDeps - - constructor(deps: SkillUpdateRunnerDeps) { - this.deps = { now: () => Date.now(), ...deps } - } - - getState(): SkillUpdateRun { - return this.run - } - - private publish(next: SkillUpdateRun): void { - this.run = next - this.deps.onState?.(next) - } - - start(names: readonly string[]): SkillUpdateStartResult { - if (this.run.state === 'running') { - return { started: false, reason: 'already-running' } - } - const canonicalNames = canonicalizeSkillUpdateNames(names) - if (!canonicalNames) { - return { started: false, reason: 'invalid-names' } - } - - const resolveCommand = this.deps.resolveCommand ?? ((name: string) => resolveCliCommand(name)) - const spawnProcess = this.deps.spawnProcess ?? spawn - const npxCommand = resolveCommand('npx') - const npxArgs = [ - '--yes', - ...buildAgentFeatureSkillInstallArgs(canonicalNames, { - sourceCommit: this.deps.sourceCommit, - yes: true, - agents: (this.deps.detectAgentKeys ?? detectSkillsCliUpdateAgentKeys)() - }) - ] - - let spawnCmd: string - let spawnArgs: string[] - try { - const buildSpawnArgs = this.deps.buildSpawnArgs ?? getSpawnArgsForWindows - ;({ spawnCmd, spawnArgs } = buildSpawnArgs(npxCommand, npxArgs)) - } catch { - // Why: the names are already canonical here, so this is the cmd.exe rail - // rejecting the resolved npx *path*. Publishing the failure keeps the dialog - // honest; a bare `started: false` would leave the button dead and silent. - this.runToken += 1 - this.settling = false - this.publish({ - state: 'error', - names: canonicalNames, - finishedAt: this.deps.now(), - output: '', - failedNames: canonicalNames, - message: - `Could not run ${npxCommand} safely from this location: its path contains one of ` + - `${WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL}, which cmd.exe would reinterpret.` - }) - return { started: false, reason: 'unsafe-command-path' } - } - - const startedAt = this.deps.now() - const token = ++this.runToken - this.settling = false - this.publish({ state: 'running', names: canonicalNames, startedAt, output: '' }) - - const child = spawnProcess(spawnCmd, spawnArgs, { - // Why: stdin ignored keeps `process.stdin.isTTY` falsy in the child, which - // is the second half of the CLI's non-interactive gate. - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - // Why: npx is an `#!/usr/bin/env node` script — without its own node ahead of - // PATH it loads under whatever version leads, and a native dep dies on ABI. - env: withCliRuntimeOnPath(npxCommand, { ...process.env, ...SKILLS_CLI_ENV_OVERRIDES }) - }) - this.child = child - - // Why: `stripAnsi` turns each \r progress frame into its own line, so an npm - // install emits many chunks a second — and every publish structured-clones - // the whole buffer to every window. Coalesce into one push per tick. - let flushTimer: ReturnType | null = null - let pendingOutput = '' - const flush = (): void => { - flushTimer = null - if (token !== this.runToken || this.run.state !== 'running' || !pendingOutput) { - return - } - const appended = pendingOutput - pendingOutput = '' - this.publish({ ...this.run, output: clampOutput(this.run.output + appended) }) - } - const append = (chunk: Buffer): void => { - if (token !== this.runToken || this.run.state !== 'running') { - return - } - pendingOutput = clampOutput(pendingOutput + stripAnsi(chunk.toString('utf8'))) - if (!flushTimer) { - flushTimer = setTimeout(flush, OUTPUT_FLUSH_MS) - flushTimer.unref?.() - } - } - child.stdout?.on('data', append) - child.stderr?.on('data', append) - // The tail matters most on failure, so never let a pending chunk die with the - // process — drain it before the exit handlers settle the run. - const drain = (): void => { - if (flushTimer) { - clearTimeout(flushTimer) - } - flush() - } - - child.on('error', (error) => { - drain() - this.settle(token, canonicalNames, error.message) - }) - child.on('close', (code) => { - drain() - this.settle(token, canonicalNames, code === 0 ? null : `skills add exited with code ${code}`) - }) - - return { started: true } - } - - private settle(token: number, names: string[], spawnError: string | null): void { - if (token !== this.runToken || this.settling || this.run.state !== 'running') { - return - } - this.settling = true - this.child = null - const output = this.run.output - const finishedAt = this.deps.now() - const rescan = this.deps.rescanOutdatedNames - - // Why: when the re-scan produces a verdict it *is* the answer — it re-hashes - // what landed on disk, which is what the user actually cares about. The exit - // code only decides the outcome when no verdict is available, because - // `skills add` reports nothing else we can trust. - const finish = (failedNames: string[] | null): void => { - // The re-scan is slow enough that a cancel — or a whole replacement run — - // can land while it is still in flight; its verdict is about a run that no - // longer exists. - if (token !== this.runToken) { - return - } - const failed = failedNames ?? (spawnError ? names : []) - if (failed.length === 0) { - this.publish({ state: 'success', names, finishedAt, output }) - return - } - this.publish({ - state: 'error', - names, - finishedAt, - output, - failedNames: failed, - message: spawnError ?? 'Some skills could not be updated.' - }) - } - - if (!rescan) { - finish(null) - return - } - void rescan(names).then( - (failedNames) => finish(failedNames), - () => finish(null) - ) - } - - cancel(): void { - if (this.killing) { - return - } - // Retire the child's handlers now so its exit settles nothing. - this.runToken += 1 - this.settling = false - const child = this.child - this.child = null - if (!child) { - if (this.run.state === 'running') { - this.publish({ state: 'idle' }) - } - return - } - - // Why: `npx` is a wrapper — on POSIX the `skills` process it execs is a - // child, and on Windows the shim runs under cmd.exe. Killing only the direct - // child leaves the process that is actually writing to the global skill - // homes alive. - this.killing = true - let hasReleased = false - let releaseTimer: ReturnType | null = null - const release = (): void => { - if (hasReleased) { - return - } - hasReleased = true - if (releaseTimer) { - clearTimeout(releaseTimer) - } - this.killing = false - // Why: stay `running` until the tree is actually dead. The sweep waits for - // a descendant snapshot before it signals anything, so releasing on the - // synchronous path would let an immediate re-Update spawn a second npx - // writing the same bundles — the corruption the post-run verdict exists to - // catch. `start()` already refuses while running, so holding the state is - // the whole guard. - if (this.run.state === 'running') { - this.publish({ state: 'idle' }) - } - } - // Every layer of the sweep is individually bounded, but this is the recovery - // path: if one ever fails to settle, the run would be stuck `running` with - // Stop already spent. Cap it rather than depend on that transitively. - releaseTimer = setTimeout(release, CANCEL_RELEASE_TIMEOUT_MS) - releaseTimer.unref?.() - if (this.run.state === 'running') { - this.publish({ ...this.run, stopping: true }) - } - - const kill = this.deps.killTree ?? killWithDescendantSweep - const pid = child.pid - if (typeof pid !== 'number') { - // Same contract as the sweep path below: a throwing kill must not escape - // and leave `killing` latched with the run stuck `running`. - try { - child.kill() - } catch { - /* already gone, or not ours to signal */ - } - release() - return - } - // Why `release` on both paths and no retry: the sweep runs `killRoot()` in its - // own `finally`, so the only way it rejects is that kill throwing (EPERM) — - // calling it again would throw straight back out of the rejection handler, - // leaving an unhandled rejection and no release at all. - void kill(pid, () => child.kill()).then(release, release) - } - - /** Clears a settled run so the status-bar segment can retire itself. */ - acknowledge(): void { - if (this.run.state === 'success' || this.run.state === 'error') { - this.publish({ state: 'idle' }) - } - } -} diff --git a/src/preload/api/agent-skill-api.ts b/src/preload/api/agent-skill-api.ts index a930218068e..ce4a3706743 100644 --- a/src/preload/api/agent-skill-api.ts +++ b/src/preload/api/agent-skill-api.ts @@ -27,10 +27,11 @@ import type { SkillShareResolvedOperation } from '../../shared/skill-sharing-contract' import type { - SkillFreshnessInventory, - SkillUpdateRun, - SkillUpdateStartResult -} from '../../shared/skill-freshness' + BundledSkillExecuteRequest, + BundledSkillInstallResult, + BundledSkillPlan, + BundledSkillPlanRequest +} from '../../shared/bundled-skill-plan' import type { SkillDeletePlan, @@ -40,11 +41,9 @@ import type { export type SkillsApi = { discover: (target?: SkillDiscoveryTarget) => Promise - freshnessInventory: () => Promise - startUpdateRun: (names: string[]) => Promise - cancelUpdateRun: () => Promise - acknowledgeUpdateRun: () => Promise - getUpdateRun: () => Promise + /** This machine's plan for Orca's installed skills; WSL and remote hosts are not planned here. */ + bundledPlan: (request?: BundledSkillPlanRequest) => Promise + bundledExecute: (request: BundledSkillExecuteRequest) => Promise prepareShare: (input: { skillIds: string[] bundleName: string @@ -89,5 +88,4 @@ export type SkillsApi = { listWslDistros: (environmentId?: string) => Promise onInstallProgress: (callback: (progress: SkillInstallProgress) => void) => () => void onShareProgress: (callback: (progress: SkillShareProgress) => void) => () => void - onUpdateRun: (callback: (run: SkillUpdateRun) => void) => () => void } diff --git a/src/preload/api/skills-bridge.ts b/src/preload/api/skills-bridge.ts index 4bcde9a613c..e84e617edc1 100644 --- a/src/preload/api/skills-bridge.ts +++ b/src/preload/api/skills-bridge.ts @@ -33,22 +33,20 @@ import type { SkillShareResolvedOperation } from '../../shared/skill-sharing-contract' import type { - SkillFreshnessInventory, - SkillUpdateRun, - SkillUpdateStartResult -} from '../../shared/skill-freshness' + BundledSkillExecuteRequest, + BundledSkillInstallResult, + BundledSkillPlan, + BundledSkillPlanRequest +} from '../../shared/bundled-skill-plan' import type { PreloadApi } from '../api-types' export const skillsApi = { discover: (target?: SkillDiscoveryTarget): Promise => ipcRenderer.invoke('skills:discover', target), - freshnessInventory: (): Promise => - ipcRenderer.invoke('skills:freshnessInventory'), - startUpdateRun: (names: string[]): Promise => - ipcRenderer.invoke('skills:startUpdateRun', names), - cancelUpdateRun: (): Promise => ipcRenderer.invoke('skills:cancelUpdateRun'), - acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), - getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), + bundledPlan: (request?: BundledSkillPlanRequest): Promise => + ipcRenderer.invoke('skills:bundledPlan', request ?? {}), + bundledExecute: (request: BundledSkillExecuteRequest): Promise => + ipcRenderer.invoke('skills:bundledExecute', request), prepareShare: (input: { skillIds: string[] bundleName: string @@ -121,10 +119,5 @@ export const skillsApi = { callback(progress) ipcRenderer.on('skills:shareProgress', listener) return () => ipcRenderer.removeListener('skills:shareProgress', listener) - }, - onUpdateRun: (callback: (run: SkillUpdateRun) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, run: SkillUpdateRun): void => callback(run) - ipcRenderer.on('skills:updateRun', listener) - return () => ipcRenderer.removeListener('skills:updateRun', listener) } } satisfies PreloadApi['skills'] diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx index 94af545daa8..3561bf37c49 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx @@ -3,7 +3,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import type { BundledSkillPlan } from '../../../../shared/bundled-skill-plan' +import { planOf, sharedEntry } from '../skills/bundled-skill-plan-fixtures.test-support' import { _skillFreshnessCacheForTests } from '@/hooks/useSkillFreshness' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' @@ -52,30 +53,8 @@ vi.mock('@/store', () => ({ }) })) -function inventory(eligibleUpdateNames: string[]): SkillFreshnessInventory { - return { - schemaVersion: 1, - // Why: an update is only advertised for a copy the skills.sh lock attributes to Orca. - installations: eligibleUpdateNames.map((name) => ({ - id: name, - name, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: `/home/.agents/skills/${name}`, - resolvedPath: `/home/.agents/skills/${name}`, - physicalIdentity: `physical-${name}`, - topology: 'canonical-copy', - status: 'outdated', - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null - })), - eligibleUpdateNames, - scanIssues: [], - scannedAt: 1 - } +function plan(updateNames: string[]): BundledSkillPlan { + return planOf(updateNames.map((name) => sharedEntry(name, 'update'))) } let root: Root | null = null @@ -121,17 +100,18 @@ describe('FloatingTerminalOrchestrationDialog freshness', () => { }) it('refreshes the local freshness verdict after presence re-check completes', async () => { - const freshnessInventory = vi + const bundledPlan = vi .fn() - .mockResolvedValueOnce(inventory(['orchestration'])) - .mockResolvedValueOnce(inventory([])) + .mockResolvedValueOnce(plan(['orchestration'])) + .mockResolvedValueOnce(plan([])) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. window.api = { - skills: { freshnessInventory }, + skills: { bundledPlan }, cli: { getInstallStatus: vi.fn().mockResolvedValue({ onPath: true }) } } as never await renderDialog() - expect(freshnessInventory).toHaveBeenCalledOnce() + expect(bundledPlan).toHaveBeenCalledOnce() expect(document.body.textContent).toContain('Update available') await act(async () => { @@ -140,15 +120,16 @@ describe('FloatingTerminalOrchestrationDialog freshness', () => { await act(async () => {}) expect(mocks.refreshOrchestrationSkill).toHaveBeenCalledOnce() - expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(bundledPlan).toHaveBeenCalledTimes(2) expect(document.body.textContent).not.toContain('Update available') }) it('never reads client freshness for an SSH-owned skill', async () => { mocks.canUseLocalSkillFreshness = false - const freshnessInventory = vi.fn().mockResolvedValue(inventory([])) + const bundledPlan = vi.fn().mockResolvedValue(plan([])) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. window.api = { - skills: { freshnessInventory }, + skills: { bundledPlan }, cli: { getInstallStatus: vi.fn().mockResolvedValue({ onPath: true }) } } as never @@ -161,6 +142,6 @@ describe('FloatingTerminalOrchestrationDialog freshness', () => { await act(async () => {}) expect(mocks.refreshOrchestrationSkill).toHaveBeenCalledOnce() - expect(freshnessInventory).not.toHaveBeenCalled() + expect(bundledPlan).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx index 14bfafacddb..4be58a24dc3 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx @@ -3,7 +3,8 @@ import { act, type ComponentProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import type { BundledSkillPlan } from '../../../../shared/bundled-skill-plan' +import { planOf, sharedEntry } from '../skills/bundled-skill-plan-fixtures.test-support' import { _skillFreshnessCacheForTests } from '@/hooks/useSkillFreshness' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' @@ -21,30 +22,8 @@ vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ OnboardingInlineCommandTerminal: () => null })) -function inventory(eligibleUpdateNames: string[]): SkillFreshnessInventory { - return { - schemaVersion: 1, - // Why: an update is only advertised for a copy the skills.sh lock attributes to Orca. - installations: eligibleUpdateNames.map((name) => ({ - id: name, - name, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: `/home/.agents/skills/${name}`, - resolvedPath: `/home/.agents/skills/${name}`, - physicalIdentity: `physical-${name}`, - topology: 'canonical-copy', - status: 'outdated', - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null - })), - eligibleUpdateNames, - scanIssues: [], - scannedAt: 1 - } +function plan(updateNames: string[]): BundledSkillPlan { + return planOf(updateNames.map((name) => sharedEntry(name, 'update'))) } function panelProps( @@ -90,7 +69,7 @@ describe('AgentSkillSetupPanel freshness re-check', () => { Reflect.deleteProperty(window, 'api') }) - it('rescans skill freshness and updates the rendered verdict on re-check', async () => { + it('re-plans skill freshness and updates the rendered verdict on re-check', async () => { let completeRecheck: (() => void) | null = null // Why: a rescan started before the install scan finishes would re-read the same // pre-update disk state, so the boundary is what the assertions below pin. @@ -100,22 +79,23 @@ describe('AgentSkillSetupPanel freshness re-check', () => { completeRecheck = resolve }) ) - let completeRescan: ((value: SkillFreshnessInventory) => void) | null = null - const freshnessInventory = vi + let completeRescan: ((value: BundledSkillPlan) => void) | null = null + const bundledPlan = vi .fn() - .mockResolvedValueOnce(inventory(['orca-linear'])) + .mockResolvedValueOnce(plan(['orca-linear'])) .mockImplementationOnce( () => - new Promise((resolve) => { + new Promise((resolve) => { completeRescan = resolve }) ) - window.api = { skills: { freshnessInventory } } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render()) await act(async () => {}) - expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(bundledPlan).toHaveBeenCalledTimes(1) expect(container?.textContent).toContain('Update available') const recheck = Array.from(container?.querySelectorAll('button') ?? []).find( @@ -130,7 +110,7 @@ describe('AgentSkillSetupPanel freshness re-check', () => { expect(onRecheck).toHaveBeenCalledOnce() expect(mocks.skillsRefreshed).not.toHaveBeenCalled() - expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(bundledPlan).toHaveBeenCalledTimes(1) await act(async () => { completeRecheck?.() @@ -138,12 +118,12 @@ describe('AgentSkillSetupPanel freshness re-check', () => { await act(async () => {}) expect(mocks.skillsRefreshed).toHaveBeenCalledOnce() - expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(bundledPlan).toHaveBeenCalledTimes(2) expect(container?.textContent).toContain('Checking...') expect(container?.textContent).not.toContain('Update available') await act(async () => { - completeRescan?.(inventory([])) + completeRescan?.(plan([])) }) await act(async () => {}) @@ -151,12 +131,13 @@ describe('AgentSkillSetupPanel freshness re-check', () => { expect(container?.textContent).not.toContain('Update available') }) - it('shows a failed verdict when the post-recheck inventory scan fails', async () => { - const freshnessInventory = vi + it('shows a failed verdict when the post-recheck plan fails', async () => { + const bundledPlan = vi .fn() - .mockResolvedValueOnce(inventory(['orca-linear'])) - .mockRejectedValueOnce(new Error('inventory unavailable')) - window.api = { skills: { freshnessInventory } } as never + .mockResolvedValueOnce(plan(['orca-linear'])) + .mockRejectedValueOnce(new Error('plan unavailable')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render()) await act(async () => {}) diff --git a/src/renderer/src/components/settings/use-settings-navigation-model.ts b/src/renderer/src/components/settings/use-settings-navigation-model.ts index 17e8e553cc5..a7b63cbe6fa 100644 --- a/src/renderer/src/components/settings/use-settings-navigation-model.ts +++ b/src/renderer/src/components/settings/use-settings-navigation-model.ts @@ -52,9 +52,7 @@ export function useSettingsNavigationModel( const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = model.computerUseSkill const capabilityInstallStatusBySectionId = useMemo(() => { - const applicableFreshnessInventory = model.skillFreshnessApplies - ? model.skillFreshnessInventory - : null + const applicableSkillPlan = model.skillFreshnessApplies ? model.skillPlan : null const next = new Map([ [ 'orchestration', @@ -62,7 +60,7 @@ export function useSettingsNavigationModel( name: ORCHESTRATION_SKILL_NAME, installed: orchestrationSkillInstalled, loading: orchestrationSkillLoading, - inventory: applicableFreshnessInventory + plan: applicableSkillPlan }) ] ]) @@ -73,7 +71,7 @@ export function useSettingsNavigationModel( skills: linearSkills, installed: linearSkillInstalled, loading: linearSkillLoading, - inventory: applicableFreshnessInventory + plan: applicableSkillPlan }) ) } @@ -84,7 +82,7 @@ export function useSettingsNavigationModel( name: COMPUTER_USE_SKILL_NAME, installed: computerUseSkillInstalled, loading: computerUseSkillLoading, - inventory: applicableFreshnessInventory + plan: applicableSkillPlan }) ) if (model.settings) { @@ -110,7 +108,7 @@ export function useSettingsNavigationModel( model.settings, model.showDesktopOnlySettings, model.skillFreshnessApplies, - model.skillFreshnessInventory, + model.skillPlan, model.voiceModelStatesLoading, orchestrationSkillInstalled, orchestrationSkillLoading diff --git a/src/renderer/src/components/settings/use-settings-store-model.ts b/src/renderer/src/components/settings/use-settings-store-model.ts index 4fe0bc20320..67d3f042b89 100644 --- a/src/renderer/src/components/settings/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/use-settings-store-model.ts @@ -129,7 +129,7 @@ export function useSettingsStoreModel() { sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const skillFreshnessApplies = activeSkillRuntime.canUseLocalSkillFreshness - const { inventory: skillFreshnessInventory } = useSkillFreshness(skillFreshnessApplies) + const { plan: skillPlan } = useSkillFreshness(skillFreshnessApplies) const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings) // Why: trim platform-only Terminal entries from the shared search index so search never reveals hidden controls. const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset') @@ -225,7 +225,7 @@ export function useSettingsStoreModel() { linearSkill, computerUseSkill, skillFreshnessApplies, - skillFreshnessInventory, + skillPlan, voiceModelStatesLoading, setVoiceModelStatesLoading, scrollbackMode, diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx index f957b7e0212..3e7595eead6 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -3,11 +3,10 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - SkillFreshnessInstallation, - SkillFreshnessInventory -} from '../../../../shared/skill-freshness' +import type { BundledSkillPlan } from '../../../../shared/bundled-skill-plan' +import { bundledSkillNudgeKey } from '../../../../shared/bundled-skill-summary' import { SkillFreshnessNudge } from './SkillFreshnessNudge' +import { planEntry, planOf, planOp, sharedEntry } from './bundled-skill-plan-fixtures.test-support' const mocks = vi.hoisted(() => ({ dismissed: [] as string[], @@ -18,47 +17,19 @@ const mocks = vi.hoisted(() => ({ settingsLoaded: true, canUseLocalSkillFreshness: true, freshnessEnabled: true, - inventory: null as SkillFreshnessInventory | null, + plan: ((): BundledSkillPlan | null => null)(), error: null as string | null })) -function placement( - overrides: Partial = {} -): SkillFreshnessInstallation { - return { - id: 'orca-cli', - name: 'orca-cli', - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: '/home/.agents/skills/orca-cli', - resolvedPath: '/home/.agents/skills/orca-cli', - physicalIdentity: 'physical-orca-cli', - topology: 'canonical-copy', - status: 'outdated', - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null, - ...overrides - } -} - -function eligibleInventory(): SkillFreshnessInventory { - return { - schemaVersion: 1, - installations: [placement()], - eligibleUpdateNames: ['orca-cli'], - scanIssues: [], - scannedAt: 1 - } +function updatePlan(): BundledSkillPlan { + return planOf([sharedEntry('orca-cli', 'update')]) } vi.mock('@/hooks/useSkillFreshness', () => ({ useSkillFreshness: (enabled = true) => { mocks.freshnessEnabled = enabled return { - inventory: mocks.inventory, + plan: mocks.plan, loading: false, error: mocks.error, refresh: vi.fn() @@ -77,7 +48,9 @@ vi.mock('sonner', () => ({ })) vi.mock('./skill-freshness-update-dialog', () => ({ - requestSkillFreshnessUpdateDialog: mocks.requestDialog + requestSkillFreshnessUpdateDialog: mocks.requestDialog, + getSkillFreshnessUpdateDialogRequest: () => false, + subscribeSkillFreshnessUpdateDialog: () => () => {} })) vi.mock('@/store', () => { @@ -108,7 +81,7 @@ async function rerenderNudge(): Promise { }) } -const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', 'current'].join('\0') +const DISMISSAL_KEY = bundledSkillNudgeKey('local', updatePlan()) ?? '' describe('SkillFreshnessNudge', () => { beforeEach(() => { @@ -116,7 +89,7 @@ describe('SkillFreshnessNudge', () => { mocks.settingsLoaded = true mocks.canUseLocalSkillFreshness = true mocks.freshnessEnabled = true - mocks.inventory = eligibleInventory() + mocks.plan = updatePlan() mocks.error = null mocks.updateSettings.mockReset() mocks.updateSettings.mockResolvedValue(undefined) @@ -145,28 +118,15 @@ describe('SkillFreshnessNudge', () => { expect(mocks.updateSettings).not.toHaveBeenCalled() }) - it('counts only Orca-owned outdated skills', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement(), - placement({ - id: 'computer-use', - name: 'computer-use', - physicalIdentity: 'physical-computer-use', - lockSource: 'acme/agent-skills' - }), - placement({ - id: 'orchestration', - name: 'orchestration', - physicalIdentity: 'physical-orchestration', - lockSource: null - }) - ], - eligibleUpdateNames: ['computer-use', 'orca-cli', 'orchestration'], - scanIssues: [], - scannedAt: 1 - } + it('counts only skills the plan would update', async () => { + mocks.plan = planOf([ + sharedEntry('orca-cli', 'update'), + sharedEntry('computer-use', 'keep'), + planEntry('orchestration', [ + planOp('keep', '/home/u/.agents/skills/orchestration'), + planOp('leave', '/home/u/.claude/skills/orchestration', { reason: 'edited' }) + ]) + ]) await renderNudge() expect(mocks.toastInfo).toHaveBeenCalledTimes(1) @@ -174,11 +134,13 @@ describe('SkillFreshnessNudge', () => { expect(mocks.toastInfo.mock.calls[0]?.[1].description).toContain('Update orca-cli so') }) - it('raises no nudge when every outdated copy came from somewhere else', async () => { - mocks.inventory = { - ...eligibleInventory(), - installations: [placement({ lockSource: 'acme/agent-skills' })] - } + it('raises no nudge when the plan only links a newly detected agent', async () => { + mocks.plan = planOf([ + planEntry('orca-cli', [ + planOp('keep', '/home/u/.agents/skills/orca-cli'), + planOp('link', '/home/u/.claude/skills/orca-cli') + ]) + ]) await renderNudge() expect(mocks.toastInfo).not.toHaveBeenCalled() @@ -199,13 +161,7 @@ describe('SkillFreshnessNudge', () => { await renderNudge() const options = mocks.toastInfo.mock.calls[0]?.[1] - mocks.inventory = { - schemaVersion: 1, - installations: [placement({ status: 'current' })], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: 2 - } + mocks.plan = planOf([sharedEntry('orca-cli', 'keep')]) await rerenderNudge() // Sonner invokes onDismiss for programmatic dismissals on its next render. options.onDismiss() @@ -214,12 +170,12 @@ describe('SkillFreshnessNudge', () => { expect(mocks.updateSettings).not.toHaveBeenCalled() }) - it('retracts a stale nudge when re-inventory fails', async () => { + it('retracts a stale nudge when re-planning fails', async () => { await renderNudge() const options = mocks.toastInfo.mock.calls[0]?.[1] - mocks.inventory = null - mocks.error = 'scan failed' + mocks.plan = null + mocks.error = 'plan failed' await rerenderNudge() options.onDismiss() @@ -240,7 +196,7 @@ describe('SkillFreshnessNudge', () => { expect(mocks.updateSettings).not.toHaveBeenCalled() }) - it('persists the exact placement/revision key once on explicit dismissal', async () => { + it('persists the plan nudge key once on explicit dismissal', async () => { await renderNudge() const options = mocks.toastInfo.mock.calls[0]?.[1] @@ -255,14 +211,14 @@ describe('SkillFreshnessNudge', () => { it('does not repeat the same nudge within a session', async () => { await renderNudge() - // A fresh inventory object with identical content re-runs the effect. - mocks.inventory = eligibleInventory() + // A fresh plan object with identical content re-runs the effect. + mocks.plan = updatePlan() await rerenderNudge() expect(mocks.toastInfo).toHaveBeenCalledTimes(1) }) - it('does not repeat a nudge for an already dismissed exact tuple', async () => { + it('does not repeat a nudge for an already dismissed key', async () => { mocks.dismissed = [DISMISSAL_KEY] await renderNudge() @@ -270,71 +226,13 @@ describe('SkillFreshnessNudge', () => { expect(mocks.toastInfo).not.toHaveBeenCalled() }) - it('does not nudge for a poisoned name with no eligible update', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [placement(), placement({ id: 'repo-copy', topology: 'repo-scope' })], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: 1 - } - - await renderNudge() - - expect(mocks.toastInfo).not.toHaveBeenCalled() - }) - - it('keeps a project copy out of the dismissal key it persists', async () => { - // Why: the global update never reaches a project copy, so its identity must not enter - // the dismissal tuple — re-checking out that repo mints a new inode, which would - // re-raise a nudge the user already dismissed for the same global revision. - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement(), - placement({ - id: 'repo-copy', - topology: 'repo-scope', - sourceKind: 'repo', - unresolvedPath: '/home/projects/work/.agents/skills/orca-cli', - resolvedPath: '/home/projects/work/.agents/skills/orca-cli', - physicalIdentity: 'physical-repo-orca-cli' - }) - ], - eligibleUpdateNames: ['orca-cli'], - scanIssues: [], - scannedAt: 1 - } - - await renderNudge() - mocks.toastInfo.mock.calls[0]?.[1].onDismiss() - - expect(mocks.updateSettings).toHaveBeenCalledWith({ - dismissedSkillFreshnessNudges: [DISMISSAL_KEY] - }) - }) - - it('stays dismissed when only a project copy changed identity', async () => { + it('nudges again when this build ships different bytes', async () => { mocks.dismissed = [DISMISSAL_KEY] - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement(), - placement({ - id: 'repo-copy', - topology: 'repo-scope', - sourceKind: 'repo', - physicalIdentity: 'physical-repo-orca-cli-after-checkout' - }) - ], - eligibleUpdateNames: ['orca-cli'], - scanIssues: [], - scannedAt: 2 - } + mocks.plan = planOf([{ ...sharedEntry('orca-cli', 'update'), digest: 'c'.repeat(64) }]) await renderNudge() - expect(mocks.toastInfo).not.toHaveBeenCalled() + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) }) it('waits for persisted settings before deciding whether to nudge', async () => { diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx index 6361f97bdbc..5addd3c2f7e 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -1,15 +1,16 @@ import { useEffect, useRef } from 'react' -import { Terminal } from 'lucide-react' +import { RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { - isOrcaSkillLockSource, - skillPlacementParticipatesInGlobalFreshness -} from '../../../../shared/skill-freshness' + bundledSkillNudgeKey, + summarizeBundledSkillPlan +} from '../../../../shared/bundled-skill-summary' import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' +import { LOCAL_SKILL_UPDATE_TARGET } from './skill-update-run-store' const MAX_DISMISSED_FRESHNESS_NUDGES = 512 const NO_DISMISSED_FRESHNESS_NUDGES: string[] = [] @@ -20,14 +21,6 @@ type ActiveFreshnessNudge = { persistDismissal: boolean } -function candidateKey(args: { - physicalIdentity: string - name: string - currentPackageDigest: string -}): string { - return [args.physicalIdentity, args.name, args.currentPackageDigest].join('\0') -} - export function SkillFreshnessNudge(): null { const activeSkillRuntime = useActiveProjectSkillRuntime() const state = useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness) @@ -50,11 +43,11 @@ export function SkillFreshnessNudge(): null { } return } - const inventory = state.inventory + const plan = state.plan if (!settingsLoaded) { return } - if (!inventory) { + if (!plan) { const active = activeNudgeRef.current if (state.error && active) { // Why: a failed re-check cannot keep advertising authority derived from @@ -65,46 +58,20 @@ export function SkillFreshnessNudge(): null { } return } - const eligibleNames = new Set(inventory.eligibleUpdateNames) - const candidates = inventory.installations.flatMap((installation) => - // Why: a project copy the global update never touches must not enter the dismissal - // fingerprint, or re-checking out that repo re-raises a nudge the user already - // dismissed — and spends the bounded dismissal budget on copies Orca cannot update. - skillPlacementParticipatesInGlobalFreshness(installation) && - installation.status === 'outdated' && - eligibleNames.has(installation.name) && - // Why: a same-named skill from somewhere else is listed in the dialog, never nudged. - isOrcaSkillLockSource(installation.lockSource) && - installation.physicalIdentity - ? [ - { - key: candidateKey({ - physicalIdentity: installation.physicalIdentity, - name: installation.name, - currentPackageDigest: installation.currentPackageDigest - }), - name: installation.name - } - ] - : [] - ) - const dismissedKeys = new Set(dismissed) - const unseen = candidates.filter((candidate) => !dismissedKeys.has(candidate.key)) - if (unseen.length === 0) { + // Why the summary's key: it changes with the host, the skills the badge counts and the + // bytes they would get, so the next Orca version nudges again. + const fingerprint = bundledSkillNudgeKey(LOCAL_SKILL_UPDATE_TARGET, plan) + if (!fingerprint || dismissed.includes(fingerprint)) { const active = activeNudgeRef.current if (active) { // Why: a resolved/replaced nudge is stale presentation, not an explicit - // user dismissal, so retract it without persisting its tuple keys. + // user dismissal, so retract it without persisting its key. active.persistDismissal = false activeNudgeRef.current = null toast.dismiss(active.id) } return } - const fingerprint = unseen - .map((candidate) => candidate.key) - .sort((left, right) => left.localeCompare(right, 'en')) - .join('\n') const active = activeNudgeRef.current if (active?.fingerprint === fingerprint) { return @@ -125,14 +92,16 @@ export function SkillFreshnessNudge(): null { } persistedFingerprints.current.add(fingerprint) const current = useAppStore.getState().settings?.dismissedSkillFreshnessNudges ?? [] - const next = [...new Set([...current, ...unseen.map((candidate) => candidate.key)])].slice( - -MAX_DISMISSED_FRESHNESS_NUDGES - ) + const next = [...new Set([...current, fingerprint])].slice(-MAX_DISMISSED_FRESHNESS_NUDGES) void updateSettings({ dismissedSkillFreshnessNudges: next }).catch(() => { persistedFingerprints.current.delete(fingerprint) }) } - const names = new Set(unseen.map((candidate) => candidate.name)) + const names = new Set( + summarizeBundledSkillPlan(plan) + .skills.filter((skill) => skill.state === 'update' || skill.state === 'repair') + .map((skill) => skill.name) + ) // Why: name the outdated skills so the nudge is actionable without opening // the modal; the sentence is translatable but the identifiers interpolate as-is. const outdatedNames = [...names] @@ -176,7 +145,7 @@ export function SkillFreshnessNudge(): null { action: { label: ( - + {names.size === 1 ? translate('auto.components.skills.SkillFreshnessNudge.updateOne', 'Update skill') : translate( @@ -203,7 +172,7 @@ export function SkillFreshnessNudge(): null { dismissed, settingsLoaded, state.error, - state.inventory, + state.plan, updateSettings ]) diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx index b0501b101ef..dddf2785e05 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx @@ -3,19 +3,20 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import type { BundledSkillPlan } from '../../../../shared/bundled-skill-plan' import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill' import { consumeSkillFreshnessUpdateDialogRequest } from './skill-freshness-update-dialog' +import { planEntry, planOf, planOp, sharedEntry } from './bundled-skill-plan-fixtures.test-support' const mocks = vi.hoisted(() => ({ - inventory: null as SkillFreshnessInventory | null, + plan: ((): BundledSkillPlan | null => null)(), loading: false, error: null as string | null })) vi.mock('@/hooks/useSkillFreshness', () => ({ useSkillFreshness: () => ({ - inventory: mocks.inventory, + plan: mocks.plan, loading: mocks.loading, error: mocks.error, refresh: vi.fn() @@ -30,34 +31,6 @@ function pillText(container: HTMLDivElement): string { return (container.textContent ?? '').replace(detailsButton(container)?.textContent ?? '', '') } -function inventory( - entries: { name: string; status: 'current' | 'outdated' | 'inaccessible' }[], - eligibleUpdateNames: string[] -): SkillFreshnessInventory { - return { - schemaVersion: 1, - installations: entries.map((entry, index) => ({ - id: `${entry.name}-${index}`, - name: entry.name, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: `/home/.agents/skills/${entry.name}`, - resolvedPath: `/home/.agents/skills/${entry.name}`, - physicalIdentity: `physical-${entry.name}-${index}`, - topology: 'canonical-copy', - status: entry.status, - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null - })), - eligibleUpdateNames, - scanIssues: [], - scannedAt: 1 - } -} - let root: Root | null = null let container: HTMLDivElement | null = null @@ -73,7 +46,7 @@ async function renderPill(skillName: string): Promise { describe('SkillFreshnessStatusPill', () => { beforeEach(() => { - mocks.inventory = null + mocks.plan = null mocks.loading = false mocks.error = null // Why: the dialog request is module-level state shared across tests. @@ -89,16 +62,16 @@ describe('SkillFreshnessStatusPill', () => { container = null }) - it('shows Update available for an eligible outdated skill', async () => { - mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli']) + it('shows Update available when the plan updates the skill', async () => { + mocks.plan = planOf([sharedEntry('orca-cli', 'update')]) const rendered = await renderPill('orca-cli') expect(pillText(rendered)).toBe('Update available') expect(detailsButton(rendered)?.textContent).toBe('Details') }) - it('shows Up to date when every placement is current', async () => { - mocks.inventory = inventory([{ name: 'orca-cli', status: 'current' }], []) + it('shows Up to date when every location is current', async () => { + mocks.plan = planOf([sharedEntry('orca-cli', 'keep')]) const rendered = await renderPill('orca-cli') expect(pillText(rendered)).toBe('Up to date') @@ -106,14 +79,21 @@ describe('SkillFreshnessStatusPill', () => { expect(detailsButton(rendered)).toBeNull() }) - it('flags a blocked outdated placement instead of reading as all-clear', async () => { - mocks.inventory = inventory( - [ - { name: 'orca-cli', status: 'outdated' }, - { name: 'orca-cli', status: 'inaccessible' } - ], - [] - ) + it('offers Details beside Up to date when an agent still needs linking', async () => { + mocks.plan = planOf([ + planEntry('orca-cli', [ + planOp('keep', '/home/u/.agents/skills/orca-cli'), + planOp('link', '/home/u/.claude/skills/orca-cli') + ]) + ]) + + const rendered = await renderPill('orca-cli') + expect(pillText(rendered)).toBe('Up to date') + expect(detailsButton(rendered)?.textContent).toBe('Details') + }) + + it('flags a copy the update leaves alone instead of reading as all-clear', async () => { + mocks.plan = planOf([sharedEntry('orca-cli', 'leave', { reason: 'edited' })]) const rendered = await renderPill('orca-cli') // Why: a green pill over a copy the update cannot reach hides real drift. @@ -121,7 +101,7 @@ describe('SkillFreshnessStatusPill', () => { expect(detailsButton(rendered)?.textContent).toBe('Details') }) - it('falls back to Installed before the inventory loads', async () => { + it('falls back to Installed before the plan loads', async () => { const rendered = await renderPill('orca-cli') expect(pillText(rendered)).toBe('Installed') expect(detailsButton(rendered)).toBeNull() @@ -144,7 +124,7 @@ describe('SkillFreshnessStatusPill', () => { }) it('opens the freshness review dialog from Details', async () => { - mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli']) + mocks.plan = planOf([sharedEntry('orca-cli', 'update')]) const rendered = await renderPill('orca-cli') await act(async () => { diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx index 73c84f560ce..157c8da75d2 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -7,6 +7,7 @@ import { AlertTriangle, ChevronRight } from 'lucide-react' import { getSkillFreshnessDisplayStatus, hasSkillCopyNeedingAttention, + skillPlanHasDetails, type SkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status' import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' @@ -46,34 +47,30 @@ function statusPill(status: SkillFreshnessDisplayStatus): React.JSX.Element { ) } -// Why: the setup rails' Installed pill is presence-only. Freshness knows more — that -// a safe update exists, that every copy is current, or that a copy is out of date -// somewhere the update cannot reach — and green must never stand in for that last -// case, which is real drift the user would otherwise have no way to see. +// Why: the setup rails' Installed pill is presence-only. The plan knows more — that an +// update exists, that every copy is current, or that a copy is left for the user — and +// green must never stand in for that last case. export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { - const { inventory, loading, error } = useSkillFreshness() - if (loading && !inventory) { + const { plan, loading, error } = useSkillFreshness() + if (loading && !plan) { return ( {translate('auto.components.skills.SkillFreshnessStatusPill.checking', 'Checking...')} ) } - if (error && !inventory) { + if (error && !plan) { return ( {translate('auto.components.skills.SkillFreshnessStatusPill.checkFailed', 'Check failed')} ) } - const status = getSkillFreshnessDisplayStatus(inventory, skillName) - // Why: the dialog lists every placement, so Details is offered whenever a placement - // is what drove the status — an available update, or a copy that blocked one. - const hasDetails = status === 'update-available' || status === 'needs-attention' - // Why: the badge alone can't say which copies are wrong, and the reasons only read - // correctly beside the locations they describe. Marking the way in is enough here — - // the dialog does the explaining, with every location and cause it knows about. - const needsAttention = hasSkillCopyNeedingAttention(inventory, skillName) + const status = getSkillFreshnessDisplayStatus(plan, skillName) + // Why: the dialog lists every location and what the update does there, so Details is + // offered whenever the plan has anything for this skill, including an agent to link. + const hasDetails = skillPlanHasDetails(plan, skillName) + const needsAttention = hasSkillCopyNeedingAttention(plan, skillName) return ( {statusPill(status)} diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index f32d5e6d57b..669a273186e 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -1,22 +1,34 @@ // @vitest-environment happy-dom -import { act, useState, type ReactNode } from 'react' +import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { - SkillFreshnessInstallation, - SkillFreshnessInventory, - SkillUpdateRun -} from '../../../../shared/skill-freshness' + BundledSkillExecuteRequest, + BundledSkillInstallResult, + BundledSkillPlan, + BundledSkillPlanRequest +} from '../../../../shared/bundled-skill-plan' import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog' import { consumeSkillFreshnessUpdateDialogRequest, requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' -import { _resetSkillUpdateRunStore, SKILL_UPDATE_SUCCESS_LINGER_MS } from './skill-update-run-store' +import { + _resetSkillUpdateRunStore, + getSkillUpdateRun, + LOCAL_SKILL_UPDATE_TARGET +} from './skill-update-run-store' +import { + doneResult, + planEntry, + planOf, + planOp, + sharedEntry +} from './bundled-skill-plan-fixtures.test-support' const mocks = vi.hoisted(() => ({ - inventory: null as SkillFreshnessInventory | null, + plan: ((): BundledSkillPlan | null => null)(), loading: false, error: null as string | null, refresh: vi.fn(), @@ -25,7 +37,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/hooks/useSkillFreshness', () => ({ useSkillFreshness: () => ({ - inventory: mocks.inventory, + plan: mocks.plan, loading: mocks.loading, error: mocks.error, refresh: mocks.refresh @@ -40,858 +52,499 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ notifyInstalledAgentSkillsChanged: mocks.notifyChanged })) -// Radix Dialog/Collapsible internals (portal, focus-scope) are exercised in -// Electron QA; here the content logic is what matters, so use plain wrappers. +// Radix Dialog internals are exercised in Electron QA; here the content matters. vi.mock('@/components/ui/dialog', () => ({ Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) => open ?
{children}
: null, DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, - DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

})) -vi.mock('@/components/ui/collapsible', () => ({ - // Forward data-* props: the unified skill row tags its Collapsible root with - // the identifiers the assertions below read. - Collapsible: ({ - children, - defaultOpen = false, - ...rest - }: { - children?: ReactNode - defaultOpen?: boolean - } & Record) => { - const [open] = useState(defaultOpen) - return ( -
- {children} -
- ) - }, - CollapsibleTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, - // Tagged so tests can prove WHERE content sits: this mock renders it whether - // or not the disclosure is open, so presence in the DOM alone proves nothing. - CollapsibleContent: ({ className, children }: { className?: string; children?: ReactNode }) => ( -
- {children} -
- ) -})) +type Deferred = { promise: Promise; resolve: (value: T) => void } +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} const skillsApi = { - startUpdateRun: vi.fn(async () => ({ started: true as const })), - cancelUpdateRun: vi.fn(async () => {}), - acknowledgeUpdateRun: vi.fn(async () => {}), - getUpdateRun: vi.fn(async (): Promise => ({ state: 'idle' })), - onUpdateRun: vi.fn((callback: (run: SkillUpdateRun) => void) => { - pushRun = callback - return () => {} + bundledPlan: vi.fn(async (_request?: BundledSkillPlanRequest): Promise => + planOf([]) + ), + bundledExecute: vi.fn( + async (_request: BundledSkillExecuteRequest): Promise => ({ + scope: 'global', + skills: [] + }) + ) +} + +const EDITED_PATH = '/home/u/.claude/skills/orca-cli' + +function editedPlan(): BundledSkillPlan { + return planOf( + [ + planEntry('orca-cli', [ + planOp('update', '/home/u/.agents/skills/orca-cli'), + planOp('leave', EDITED_PATH, { reason: 'edited', replaceable: true }) + ]) + ], + '1'.repeat(64) + ) +} + +function forcedPlan(): BundledSkillPlan { + return planOf( + [ + planEntry('orca-cli', [ + planOp('update', '/home/u/.agents/skills/orca-cli'), + planOp('replace', EDITED_PATH, { content: 'link' }) + ]) + ], + '2'.repeat(64) + ) +} + +let container: HTMLDivElement +let root: Root + +function render(): void { + act(() => { + root.render() }) } -let pushRun: ((run: SkillUpdateRun) => void) | null = null -function placement( - name: string, - overrides: Partial = {} -): SkillFreshnessInstallation { - return { - id: `${name}-${overrides.rootId ?? 'home-agents'}`, - name, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: `/home/.agents/skills/${name}`, - resolvedPath: `/home/.agents/skills/${name}`, - physicalIdentity: `physical-${name}`, - topology: 'canonical-copy', - status: 'outdated', - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null, - ...overrides - } -} - -function eligibleInventory(): SkillFreshnessInventory { - return { - schemaVersion: 1, - installations: [placement('orca-cli')], - eligibleUpdateNames: ['orca-cli'], - scanIssues: [], - scannedAt: 1 - } -} - -let root: Root | null = null -let container: HTMLDivElement | null = null - -async function renderDialog(): Promise { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) +async function flush(): Promise { await act(async () => { - root?.render() + await Promise.resolve() + await Promise.resolve() }) } -async function rerender(): Promise { - await act(async () => { - root?.render() - }) -} - -async function openViaRequest(): Promise { - await act(async () => { - requestSkillFreshnessUpdateDialog() - }) -} - -function findButton(label: string): HTMLButtonElement | undefined { - return Array.from(container?.querySelectorAll('button') ?? []).find( +function button(label: string): HTMLButtonElement { + const found = [...container.querySelectorAll('button')].find( (candidate) => candidate.textContent?.trim() === label ) + if (!found) { + throw new Error(`No button "${label}" in: ${container.textContent}`) + } + return found } -async function clickButton(label: string): Promise { - const button = findButton(label) - expect(button, `expected a "${label}" button`).toBeDefined() - await act(async () => { - button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) +function click(element: Element): void { + act(() => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) } -async function emitRun(run: SkillUpdateRun): Promise { - await act(async () => { - pushRun?.(run) - }) +function replaceCheckbox(): HTMLElement { + const label = [...container.querySelectorAll('label')].find((candidate) => + candidate.textContent?.includes('Replace') + ) + const checkbox = label?.querySelector('[role="checkbox"]') + if (!(checkbox instanceof HTMLElement)) { + throw new Error('No Replace toggle') + } + return checkbox } +beforeEach(() => { + mocks.plan = null + mocks.loading = false + mocks.error = null + mocks.refresh.mockReset() + mocks.notifyChanged.mockReset() + skillsApi.bundledPlan.mockReset() + skillsApi.bundledExecute.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: skillsApi } + }) + _resetSkillUpdateRunStore() + consumeSkillFreshnessUpdateDialogRequest() + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + requestSkillFreshnessUpdateDialog() +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + consumeSkillFreshnessUpdateDialogRequest() + _resetSkillUpdateRunStore() +}) + describe('SkillFreshnessUpdateDialog', () => { - beforeEach(() => { - consumeSkillFreshnessUpdateDialogRequest() - _resetSkillUpdateRunStore() - pushRun = null - mocks.inventory = eligibleInventory() - mocks.loading = false - mocks.error = null - mocks.refresh.mockReset() - mocks.notifyChanged.mockReset() - skillsApi.startUpdateRun.mockClear() - skillsApi.cancelUpdateRun.mockClear() - skillsApi.acknowledgeUpdateRun.mockClear() - skillsApi.getUpdateRun.mockClear() - ;(window as unknown as { api: { skills: typeof skillsApi } }).api = { skills: skillsApi } - }) - - afterEach(async () => { - if (root) { - await act(async () => root?.unmount()) - } - root = null - container?.remove() - container = null - }) - - it('stays closed until an open request arrives', async () => { - await renderDialog() - expect(container?.querySelector('[data-dialog-open]')).toBeNull() - }) - - it('offers a primary update action and never renders a terminal', async () => { - await renderDialog() - await openViaRequest() - - expect(container?.textContent).toContain('Update skills') - expect(container?.textContent).toContain('1 update available') - expect(findButton('Update 1 skill')).toBeDefined() - expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() - expect(container?.textContent).not.toContain('press Enter') - }) - - it('starts a background run with the eligible names', async () => { - await renderDialog() - await openViaRequest() - await clickButton('Update 1 skill') - - expect(skillsApi.startUpdateRun).toHaveBeenCalledWith(['orca-cli']) - }) - - it('shows indeterminate progress and says the run survives closing', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ state: 'running', names: ['orca-cli'], startedAt: 1, output: '' }) - - expect(container?.textContent).toContain('Updating 1 skill…') - expect(container?.textContent).toContain('keeps running in the background') - expect(container?.querySelector('[role="progressbar"]')).not.toBeNull() - expect( - container?.querySelector('[data-skill-row="orca-cli"]')?.getAttribute('data-state-label') - ).toBe('pending') - }) - - it('does not cancel the run when the dialog is closed', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ state: 'running', names: ['orca-cli'], startedAt: 1, output: '' }) - await clickButton('Close') - - expect(container?.querySelector('[data-dialog-open]')).toBeNull() - expect(skillsApi.cancelUpdateRun).not.toHaveBeenCalled() - }) - - it('reports per-skill success once the run settles', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ - state: 'success', - names: ['orca-cli'], - finishedAt: 2, - output: '✓ Updated 1 skill(s)' - }) - - expect(container?.textContent).toContain('Updated 1 skill') - expect( - container?.querySelector('[data-skill-row="orca-cli"]')?.getAttribute('data-state-label') - ).toBe('done') - expect(findButton('Done')).toBeDefined() - // The re-scan is what makes the result trustworthy, so it must be requested. - expect(mocks.notifyChanged).toHaveBeenCalled() - }) - - it('attributes failures to the names the re-scan says are still outdated', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [placement('orca-cli'), placement('orchestration')], - eligibleUpdateNames: ['orca-cli', 'orchestration'], - scanIssues: [], - scannedAt: 1 - } - await renderDialog() - await openViaRequest() - await emitRun({ - state: 'error', - names: ['orca-cli', 'orchestration'], - failedNames: ['orchestration'], - finishedAt: 3, - output: '✗ Failed to update orchestration', - message: 'skills add exited with code 1' - }) - - expect(container?.textContent).toContain('Updated 1 of 2 skills') - expect( - container?.querySelector('[data-skill-row="orca-cli"]')?.getAttribute('data-state-label') - ).toBe('done') - expect( - container?.querySelector('[data-skill-row="orchestration"]')?.getAttribute('data-state-label') - ).toBe('failed') - expect(container?.textContent).toContain('skills add exited with code 1') - expect(findButton('Retry')).toBeDefined() - }) - - it('keeps the same row elements across the whole run instead of swapping layouts', async () => { - await renderDialog() - await openViaRequest() - const before = container?.querySelector('[data-skill-row="orca-cli"]') - expect(before?.getAttribute('data-state-label')).toBe('available') - - await emitRun({ state: 'running', names: ['orca-cli'], startedAt: 1, output: '' }) - const during = container?.querySelector('[data-skill-row="orca-cli"]') - expect(during).toBe(before) - expect(during?.getAttribute('data-state-label')).toBe('pending') - - // The re-scan makes the skill current, which would otherwise drop it from - // the group list and blank the row out mid-transition. - mocks.inventory = { - schemaVersion: 1, - installations: [placement('orca-cli', { status: 'current' })], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: 5 - } - await emitRun({ state: 'success', names: ['orca-cli'], finishedAt: 2, output: 'done' }) - await rerender() - const after = container?.querySelector('[data-skill-row="orca-cli"]') - expect(after).toBe(before) - expect(after?.getAttribute('data-state-label')).toBe('done') - }) - - it('collapses each skill’s locations behind its own disclosure', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement('orca-cli'), - placement('orca-cli', { - rootId: 'plugin', - topology: 'plugin-cache', - status: 'inaccessible' + it('lists only skills with something to do, and every location on request', () => { + mocks.plan = planOf([ + planEntry('orca-cli', [ + planOp('update', '/home/u/.agents/skills/orca-cli'), + planOp('keep', '/home/u/.claude/skills/orca-cli', { + observed: { kind: 'link', target: '../../.agents/skills/orca-cli' } }) - ], - eligibleUpdateNames: ['orca-cli'], - scanIssues: [], - scannedAt: 1 - } - await renderDialog() - await openViaRequest() - - const row = container?.querySelector('[data-skill-row="orca-cli"]') - expect(row).not.toBeNull() - // Closed by default — the paths are behind the row's own trigger. - expect(row?.getAttribute('data-collapsible-open')).toBe('false') - expect(container?.textContent).toContain('2 locations') - }) - - it('does not let the status-bar linger retire the result being read here', async () => { - vi.useFakeTimers() - try { - await renderDialog() - await openViaRequest() - await emitRun({ state: 'success', names: ['orca-cli'], finishedAt: 2, output: 'done' }) - - await act(async () => { - vi.advanceTimersByTime(SKILL_UPDATE_SUCCESS_LINGER_MS * 3) - }) - - expect(skillsApi.acknowledgeUpdateRun).not.toHaveBeenCalled() - expect(container?.textContent).toContain('Updated 1 skill') - expect(findButton('Done')).toBeDefined() - expect( - container?.querySelector('[data-skill-row="orca-cli"]')?.getAttribute('data-state-label') - ).toBe('done') - - // Closing is what hands the run back — it must not be left stuck. - await clickButton('Done') - expect(skillsApi.acknowledgeUpdateRun).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('shows the captured log verbatim without parsing it', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ - state: 'success', - names: ['orca-cli'], - finishedAt: 2, - output: 'Checking skills from source: stablyai/orca\n ✓ Updated orca-cli' - }) - - expect(container?.querySelector('pre')?.textContent).toContain( - 'Checking skills from source: stablyai/orca' - ) - }) - - it('contains unbroken failure output inside the dialog grid', async () => { - const unbrokenOutput = 'A'.repeat(1000) - const unbrokenMessage = 'B'.repeat(1000) - await renderDialog() - await openViaRequest() - await emitRun({ - state: 'error', - names: ['orca-cli'], - failedNames: ['orca-cli'], - finishedAt: 3, - output: unbrokenOutput, - message: unbrokenMessage - }) - - const output = container?.querySelector('pre') - expect(output?.textContent).toContain(unbrokenOutput) - expect(output?.className).toContain('[overflow-wrap:anywhere]') - expect(output?.className).not.toContain('break-words') - expect(output?.parentElement?.className).toContain('min-w-0') - expect(output?.closest('[data-collapsible-open]')?.className).toContain('min-w-0') - - const message = Array.from(container?.querySelectorAll('p') ?? []).find( - (candidate) => candidate.textContent === unbrokenMessage - ) - expect(message?.className).toContain('[overflow-wrap:anywhere]') - expect(message?.parentElement?.className).toContain('min-w-0') - }) - - it('shows the up-to-date state once every installation is current', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [placement('orca-cli', { status: 'current' })], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: 2 - } - await renderDialog() - await openViaRequest() - - expect(container?.textContent).toContain('All installed Orca skills are up to date.') - expect(findButton('Update 1 skill')).toBeUndefined() - }) - - it('shows why a skill was skipped without needing the disclosure', async () => { - mocks.inventory = { - schemaVersion: 1, - // Why: a read-only copy rather than a project one — a project-owned copy raises no - // row at all now, so it cannot carry this assertion about how a row renders. - installations: [placement('computer-use', { topology: 'read-only' })], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: 3 - } - await renderDialog() - await openViaRequest() - - const row = container?.querySelector('[data-skill-row="computer-use"]') - expect(container?.textContent).toContain('computer-use') - expect(container?.textContent).toContain('Skipped') - // The reason is the one thing a skipped row exists to say, so it lives - // outside the disclosure — visible with the row still collapsed, and not - // dependent on a mount-time `defaultOpen` a later re-scan could never re-fire. - expect(row?.getAttribute('data-collapsible-open')).toBe('false') - expect(container?.textContent).toContain('This copy is in a read-only location') - // Assert the placement, not just the presence: moving it back inside the - // disclosure would hide it in production but still satisfy `textContent`. - expect(row?.querySelector('[data-collapsible-content]')?.textContent).not.toContain( - 'This copy is in a read-only location' - ) - }) - - it('raises no row for a skill whose only finding is a project-owned copy', async () => { - // The reported bug: a pristine global install plus a drifted copy inside a work - // directory. Orca only ever updates global skills, so a "Skipped" row here asserts - // it considered an update it could never perform. - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement('computer-use', { status: 'current' }), - placement('computer-use', { - rootId: 'repo-work', - sourceKind: 'repo', - topology: 'repo-scope', - status: 'outdated', - unresolvedPath: '/home/projects/work/.agents/skills/computer-use', - resolvedPath: '/home/projects/work/.agents/skills/computer-use', - physicalIdentity: 'physical-repo-computer-use' - }) - ], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: 4 - } - await renderDialog() - await openViaRequest() - - expect(container?.textContent).toContain('All installed Orca skills are up to date.') - expect(container?.querySelector('[data-skill-row="computer-use"]')).toBeNull() - }) - - it('still lists a project copy when another placement earns the group', async () => { - // The converse: ownership suppresses the group, never the visibility of a location - // that exists. An outdated global copy earns the row, and the project copy has to - // stay listed there so the user can see every place the skill lives. - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement('computer-use', { topology: 'read-only' }), - placement('computer-use', { - rootId: 'repo-work', - sourceKind: 'repo', - topology: 'repo-scope', - status: 'outdated', - unresolvedPath: '/home/projects/work/.agents/skills/computer-use', - resolvedPath: '/home/projects/work/.agents/skills/computer-use', - physicalIdentity: 'physical-repo-computer-use' - }) - ], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: 5 - } - await renderDialog() - await openViaRequest() - - const row = container?.querySelector('[data-skill-row="computer-use"]') - expect(row).not.toBeNull() - expect(row?.textContent).toContain('/home/projects/work/.agents/skills/computer-use') - }) - - it('preselects Orca-owned updates and leaves same-named skills from elsewhere unticked', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement('computer-use', { lockSource: 'acme/agent-skills' }), - placement('linear-tickets', { lockSource: null }), - placement('orca-cli'), - placement('orchestration', { lockSource: 'https://github.com/stablyai/orca.git' }) - ], - eligibleUpdateNames: ['computer-use', 'linear-tickets', 'orca-cli', 'orchestration'], - scanIssues: [], - scannedAt: 7 - } - await renderDialog() - await openViaRequest() - - // Orca's own rows sort first, and only they are checked. - const rows = Array.from(container?.querySelectorAll('[data-skill-row]') ?? []) - expect(rows.map((row) => row.getAttribute('data-skill-row'))).toEqual([ - 'orca-cli', - 'orchestration', - 'computer-use', - 'linear-tickets' + ]), + sharedEntry('orchestration', 'keep'), + sharedEntry('orca-linear', 'keep') ]) - const checked = (name: string): string | null | undefined => - container - ?.querySelector(`[role="checkbox"][aria-label="Include ${name}"]`) - ?.getAttribute('aria-checked') - expect(checked('orca-cli')).toBe('true') - expect(checked('orchestration')).toBe('true') - expect(checked('computer-use')).toBe('false') - expect(checked('linear-tickets')).toBe('false') - expect(container?.querySelector('[data-skill-row="computer-use"]')?.textContent).toContain( - 'Not from Orca' - ) - expect(container?.querySelector('[data-skill-row="orca-cli"]')?.textContent).toContain( - 'Update available' - ) - expect(container?.textContent).toContain('2 updates available') - expect(container?.textContent).toContain('2 skills not installed by Orca are unselected.') + render() - await clickButton('Update 2 skills') - expect(skillsApi.startUpdateRun).toHaveBeenLastCalledWith(['orca-cli', 'orchestration']) + expect(container.textContent).toContain('1 update available') + expect(container.querySelector('[data-skill-row="orca-cli"]')?.textContent).toBe( + 'orca-cliUpdate available' + ) + expect(container.querySelector('[data-skill-row="orchestration"]')).toBeNull() + expect(container.textContent).toContain('2 skills are up to date.') + expect(button('Update 1 skill').disabled).toBe(false) + + click(button('Show folders')) + const row = container.querySelector('[data-skill-row="orca-cli"]')?.textContent ?? '' + expect(row).toContain('Shared folder · ~/.agents/skills/orca-cli') + expect(row).toContain('Claude Code · ~/.claude/skills/orca-cli') + expect(container.querySelector('[data-skill-row="orchestration"]')).not.toBeNull() + click(button('Hide folders')) + expect(container.textContent).not.toContain('~/.agents/skills/orca-cli') }) - it('counts the selection in the button once the user ticks a foreign copy', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement('computer-use', { lockSource: 'acme/agent-skills' }), - placement('orca-cli') - ], - eligibleUpdateNames: ['computer-use', 'orca-cli'], - scanIssues: [], - scannedAt: 8 - } - await renderDialog() - await openViaRequest() - const foreign = container?.querySelector('[aria-label="Include computer-use"]') + it('labels a link-only plan as installing for the agent, not an update', () => { + mocks.plan = planOf([ + planEntry('orca-cli', [ + planOp('keep', '/home/u/.agents/skills/orca-cli'), + planOp('link', '/home/u/.claude/skills/orca-cli') + ]) + ]) + render() + + expect(container.textContent).toContain('All installed Orca skills are up to date.') + expect(button('Install for Claude Code').disabled).toBe(false) + }) + + it('names a copy left alone by its agent, with its Replace choice', () => { + mocks.plan = editedPlan() + render() + + const row = container.querySelector('[data-skill-row="orca-cli"]') + expect(row?.textContent).toContain('Update skipped: Skill edited outside Orca') + expect(row?.textContent).toContain("Replace with Orca's copy (keeps a backup)") + }) + + it('re-plans with force when Replace is toggled and executes that plan', async () => { + mocks.plan = editedPlan() + const forced = deferred() + skillsApi.bundledPlan.mockReturnValueOnce(forced.promise) + render() + + click(replaceCheckbox()) + expect(skillsApi.bundledPlan).toHaveBeenCalledWith({ force: [EDITED_PATH] }) + // Why: the forced plan has not landed, so Update must not run the unforced one. + expect(button('Update 1 skill').disabled).toBe(true) + await act(async () => { - foreign?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + forced.resolve(forcedPlan()) + await forced.promise }) + expect(container.querySelector('[data-skill-row="orca-cli"]')?.textContent).toBe( + "orca-cliReplaceReplace with Orca's copy (keeps a backup)" + ) + skillsApi.bundledExecute.mockResolvedValueOnce(doneResult(forcedPlan())) + click(button('Update 1 skill')) + await flush() - expect(foreign?.getAttribute('aria-checked')).toBe('true') - expect(container?.textContent).not.toContain('not installed by Orca') - await clickButton('Update 2 skills') - // Display order, Orca's first; the runner canonicalizes. - expect(skillsApi.startUpdateRun).toHaveBeenLastCalledWith(['orca-cli', 'computer-use']) - }) - - it('disables the update once nothing is selected', async () => { - await renderDialog() - await openViaRequest() - await act(async () => { - container - ?.querySelector('[aria-label="Include orca-cli"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(skillsApi.bundledExecute).toHaveBeenCalledWith({ + planDigest: '2'.repeat(64), + force: [EDITED_PATH] }) - - expect(findButton('Update 0 skills')).toBeUndefined() - expect(findButton('Update skills')?.disabled).toBe(true) - }) - - it('drops a choice when a re-check finds a different install source', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement('computer-use', { lockSource: 'acme/agent-skills' }), - placement('orca-cli') - ], - eligibleUpdateNames: ['computer-use', 'orca-cli'], - scanIssues: [], - scannedAt: 10 - } - await renderDialog() - await openViaRequest() - await act(async () => { - container - ?.querySelector('[aria-label="Include computer-use"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - // The re-check now attributes orca-cli to someone else: its preselection was Orca's, - // not the user's, so it must not be replaced without a fresh tick. - mocks.inventory = { - ...mocks.inventory, - installations: [ - placement('computer-use', { lockSource: 'acme/agent-skills' }), - placement('orca-cli', { lockSource: 'acme/agent-skills' }) - ], - scannedAt: 11 - } - await rerender() - - expect( - container?.querySelector('[aria-label="Include orca-cli"]')?.getAttribute('aria-checked') - ).toBe('false') - await clickButton('Update 1 skill') - expect(skillsApi.startUpdateRun).toHaveBeenLastCalledWith(['computer-use']) - }) - - it('reads Orca’s skills as current when only a foreign copy is outdated', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [ - placement('computer-use', { lockSource: null }), - placement('orca-cli', { status: 'current' }) - ], - eligibleUpdateNames: ['computer-use'], - scanIssues: [], - scannedAt: 9 - } - await renderDialog() - await openViaRequest() - - expect(container?.textContent).toContain('All installed Orca skills are up to date.') - expect(container?.textContent).toContain('1 skill not installed by Orca is unselected.') - expect(findButton('Update skills')?.disabled).toBe(true) - }) - - it('stays coherent while an idle re-scan is in flight', async () => { - await renderDialog() - await openViaRequest() - // Re-check publishes {inventory: null, loading: true} synchronously. The rows - // are retained, so the headline and the count must not be read off the live - // snapshot and the retained one at the same time — that pairs a "0 updates - // available" headline with rows badged "Update available". - mocks.inventory = null - mocks.loading = true - await rerender() - - expect(container?.textContent).not.toContain('0 updates available') - expect(container?.textContent).toContain('Checking installed Orca skills…') - // The action keeps its place rather than reflowing the footer, but cannot - // fire against bytes that are being re-read. - const update = findButton('Update 1 skill') - expect(update).toBeDefined() - expect(update?.disabled).toBe(true) - expect( - container?.querySelector('[data-skill-row="orca-cli"]')?.getAttribute('data-state-label') - ).toBe('available') - }) - - it('says it is stopping while the process tree is still being killed', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ state: 'running', names: ['orca-cli'], startedAt: 1, output: '' }) - await clickButton('Stop') - // Main holds the run `running` until the kill lands — that is what blocks a - // second writer — so the button must not sit enabled and inert meanwhile. - await emitRun({ - state: 'running', - names: ['orca-cli'], - startedAt: 1, - output: '', - stopping: true - }) - - const stopping = findButton('Stopping…') - expect(stopping).toBeDefined() - expect(stopping?.disabled).toBe(true) - expect(findButton('Stop')).toBeUndefined() - // The headline must not contradict the button — telling someone the update - // "keeps running in the background" is the opposite of what Stop just did. - expect(container?.textContent).toContain('Stopping the update…') - expect(container?.textContent).not.toContain('keeps running in the background') - expect(container?.textContent).not.toContain('Updating 1 skill…') - // The primary button sits right next to "Stopping…" — it must not still be - // announcing "Updating…", visually or to a screen reader. - expect(findButton('Updating…')).toBeUndefined() - expect(container?.querySelector('[role="progressbar"]')?.getAttribute('aria-label')).toBe( - 'Stopping the update…' + expect(container.querySelector('[data-skill-row="orca-cli"]')?.textContent).toBe( + 'orca-cliReplaced · Your copy was saved as a backup' ) }) - it('re-reads the inventory after a run is stopped', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ state: 'running', names: ['orca-cli'], startedAt: 1, output: '' }) - mocks.notifyChanged.mockClear() - // A killed run may already have written several skills; leaving the pre-run - // scan on screen would re-offer skills that are now current. - await emitRun({ state: 'idle' }) - - expect(mocks.notifyChanged).toHaveBeenCalled() - }) - - it('keeps the rows on screen while the settling re-scan is in flight', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ state: 'success', names: ['orca-cli'], finishedAt: 2, output: 'done' }) - - // Settling notifies every skills surface, and that refresh nulls the - // inventory synchronously while it re-hashes every package on disk. - mocks.inventory = null - mocks.loading = true - await rerender() - - expect( - container?.querySelector('[data-skill-row="orca-cli"]')?.getAttribute('data-state-label') - ).toBe('done') - expect(container?.textContent).toContain('Updated 1 skill') - }) - - it('retries what failed rather than the emptied eligibility list', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ - state: 'error', - names: ['orca-cli'], - failedNames: ['orca-cli'], - finishedAt: 3, - output: '', - message: 'skills add exited with code 1' - }) - // The settling re-scan has emptied `eligibleUpdateNames` by the time Retry - // is on screen; retrying that list would spawn nothing at all. - mocks.inventory = null - mocks.loading = true - await rerender() - await clickButton('Retry') - - expect(skillsApi.startUpdateRun).toHaveBeenCalledWith(['orca-cli']) - }) - - it('offers a way out of a run that never finishes', async () => { - await renderDialog() - await openViaRequest() - await emitRun({ state: 'running', names: ['orca-cli'], startedAt: 1, output: '' }) - await clickButton('Stop') - - expect(skillsApi.cancelUpdateRun).toHaveBeenCalledTimes(1) - }) - - it('surfaces a scan error instead of a stale summary', async () => { - await renderDialog() - await openViaRequest() - // Prime a good scan first: the retained inventory must not survive into the - // error state. That invariant lives in useSkillFreshness (every publish that - // sets `error` also clears `loading`), so pin it from the side that depends - // on it — otherwise a later "keep spinning while retrying" change would - // silently start showing stale rows under an error. - expect(container?.querySelector('[data-skill-row="orca-cli"]')).not.toBeNull() - - mocks.inventory = null - mocks.error = 'Missing canonical agent skills root' - await rerender() - - expect(container?.textContent).toContain('Missing canonical agent skills root') - expect(container?.querySelector('[data-skill-row="orca-cli"]')).toBeNull() - expect(findButton('Update 1 skill')).toBeUndefined() - }) - - it('wraps an unbroken scan error inside the dialog grid', async () => { - const unbrokenError = 'C'.repeat(1000) - mocks.inventory = null - mocks.error = unbrokenError - await renderDialog() - await openViaRequest() - - const error = Array.from(container?.querySelectorAll('p') ?? []).find( - (candidate) => candidate.textContent === unbrokenError + it('keeps an edited row in place with one Replace status when selected', async () => { + mocks.plan = planOf([ + planEntry('orca-cli', [ + planOp('leave', EDITED_PATH, { reason: 'edited', replaceable: true }) + ]), + sharedEntry('orchestration', 'update') + ]) + skillsApi.bundledPlan.mockResolvedValueOnce( + planOf([ + planEntry('orca-cli', [planOp('replace', EDITED_PATH)]), + sharedEntry('orchestration', 'update') + ]) ) - expect(error?.className).toContain('min-w-0') - expect(error?.className).toContain('[overflow-wrap:anywhere]') - }) - - it('shows incomplete plugin coverage without presenting a fabricated skill copy', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [placement('orca-cli', { status: 'current' })], - eligibleUpdateNames: [], - scanIssues: [ - { - rootId: 'codex-plugin-cache', - sourceLabel: 'Codex plugin cache', - path: '/home/.codex/plugins/cache/vendor/locked', - reason: 'io-error', - errorCode: 'EACCES' - } - ], - scannedAt: 2 - } - await renderDialog() - await openViaRequest() - - expect(container?.textContent).toContain( - 'Orca could not finish checking plugin-managed skills.' - ) - expect(container?.textContent).toContain('/home/.codex/plugins/cache/vendor/locked') - expect(container?.textContent).toContain('EACCES') - expect(container?.textContent).not.toContain('All installed Orca skills are up to date.') - // Why: the fabricated per-skill path is exactly what this change removed — the - // unreadable folder must never be rendered as a copy of a named skill. - expect(container?.textContent).not.toContain( - '/home/.codex/plugins/cache/vendor/locked/orca-cli' - ) - }) - - // Why: the walk stopped early here, so claiming every copy is up to date would assert - // a completeness the scan did not reach — green dishonesty in place of amber. - it.each(['entry-limit', 'candidate-limit'] as const)( - 'does not report all-clear when %s ended the scan early', - async (reason) => { - mocks.inventory = { - schemaVersion: 1, - installations: [placement('orca-cli', { status: 'current' })], - eligibleUpdateNames: [], - scanIssues: [ - { - rootId: 'codex-plugin-cache', - sourceLabel: 'Codex plugin cache', - path: '/home/.codex/plugins/cache', - reason: reason, - errorCode: null - } - ], - scannedAt: 2 - } - await renderDialog() - await openViaRequest() - - expect(container?.textContent).not.toContain('All installed Orca skills are up to date.') - expect(container?.textContent).toContain( - 'Orca could not finish checking plugin-managed skills.' + render() + const rowNames = () => + [...container.querySelectorAll('[data-skill-row]')].map((row) => + row.getAttribute('data-skill-row') ) - // Why: the headline alone would pass with the folder list gone, leaving the user - // told the scan stopped but never told where. Assert the diagnostic renders too. - expect(container?.textContent).toContain('/home/.codex/plugins/cache') + expect(rowNames()).toEqual(['orchestration', 'orca-cli']) + + click(replaceCheckbox()) + await flush() + + expect(rowNames()).toEqual(['orchestration', 'orca-cli']) + const row = container.querySelector('[data-skill-row="orca-cli"]') + expect(row?.textContent).toBe("orca-cliReplaceReplace with Orca's copy (keeps a backup)") + expect(replaceCheckbox().getAttribute('aria-checked')).toBe('true') + + click(replaceCheckbox()) + await flush() + expect(rowNames()).toEqual(['orchestration', 'orca-cli']) + expect(row?.textContent).toContain('Update skipped: Skill edited outside Orca') + }) + + it('reports each skill with its left-alone copies after the run', async () => { + const plan = editedPlan() + mocks.plan = plan + skillsApi.bundledExecute.mockResolvedValueOnce(doneResult(plan)) + render() + + click(button('Update 1 skill')) + await flush() + + expect(skillsApi.bundledExecute).toHaveBeenCalledWith({ + planDigest: plan.planDigest, + force: [] + }) + expect(container.textContent).toContain('Updated 1 skill · 1 skipped') + const row = container.querySelector('[data-skill-row="orca-cli"]')?.textContent ?? '' + expect(row).toContain('Updated') + expect(row).toContain('Update skipped: Skill edited outside Orca') + expect(mocks.notifyChanged).toHaveBeenCalled() + expect(button('Done')).toBeTruthy() + }) + + it.each([false, true])('keeps Update available after recovery (mixed run: %s)', async (mixed) => { + const repair = planOf([ + sharedEntry('orca-cli', 'restore'), + ...(mixed ? [sharedEntry('orchestration', 'update')] : []) + ]) + const update = planOf([sharedEntry('orca-cli', 'update')], '3'.repeat(64)) + mocks.plan = repair + skillsApi.bundledExecute.mockResolvedValueOnce(doneResult(repair)) + render() + + click(button(mixed ? 'Update 2 skills' : 'Update 1 skill')) + await flush() + + expect(container.textContent).not.toContain(mixed ? 'Updated 1 skill' : 'Updated 0 skills') + expect(container.querySelector('.text-status-success')).toBeNull() + expect(container.querySelector('.lucide-triangle-alert')?.parentElement?.textContent).toBe( + 'Needs attention' + ) + expect(getSkillUpdateRun()).toMatchObject({ + state: 'done', + summary: { ok: true, exitCode: 0, changedCount: mixed ? 1 : 0 } + }) + + mocks.plan = null + render() + expect(button(mixed ? 'Update 2 skills' : 'Update 1 skill').disabled).toBe(true) + + mocks.plan = update + skillsApi.bundledExecute.mockResolvedValueOnce(doneResult(update)) + render() + expect(button('Update 1 skill').disabled).toBe(false) + click(button('Update 1 skill')) + await flush() + + expect(skillsApi.bundledExecute).toHaveBeenLastCalledWith({ + planDigest: update.planDigest, + force: [] + }) + expect(container.textContent).toContain('Updated 1 skill') + expect(button('Done')).toBeTruthy() + }) + + it('counts skills rather than folders in the preview and result headlines', async () => { + const plan = planOf([ + planEntry('orca-cli', [ + planOp('update', '/home/u/.agents/skills/orca-cli'), + planOp('update', EDITED_PATH) + ]), + sharedEntry('orchestration', 'update'), + planEntry('computer-use', [ + planOp('leave', '/home/u/.agents/skills/computer-use', { reason: 'edited' }), + planOp('leave', '/home/u/.claude/skills/computer-use', { reason: 'shared-not-current' }), + planOp('leave', '/home/u/.cursor/skills/computer-use', { reason: 'shared-not-current' }) + ]) + ]) + mocks.plan = plan + skillsApi.bundledExecute.mockResolvedValueOnce(doneResult(plan)) + render() + + expect(container.textContent).toContain('2 updates available') + click(button('Update 2 skills')) + await flush() + + expect(container.textContent).toContain('Updated 2 skills · 1 skipped') + }) + + it('keeps preview row order through a replacement run and the refreshed plan', async () => { + const replacementPath = '/home/u/.claude/skills/computer-use' + mocks.plan = planOf([ + planEntry('computer-use', [ + planOp('leave', replacementPath, { reason: 'edited', replaceable: true }) + ]), + sharedEntry('orca-cli', 'update'), + sharedEntry('agent-discord', 'leave', { reason: 'unwritable' }), + sharedEntry('orchestration', 'restore') + ]) + const forced = planOf( + mocks.plan.skills.map((entry) => + entry.name === 'computer-use' + ? planEntry(entry.name, [planOp('replace', replacementPath)]) + : entry + ), + '2'.repeat(64) + ) + skillsApi.bundledPlan.mockResolvedValueOnce(forced) + const execution = deferred() + skillsApi.bundledExecute.mockReturnValueOnce(execution.promise) + const rowNames = () => + [...container.querySelectorAll('[data-skill-row]')].map((row) => + row.getAttribute('data-skill-row') + ) + const previewOrder = ['orchestration', 'orca-cli', 'computer-use', 'agent-discord'] + render() + expect(rowNames()).toEqual(previewOrder) + click(replaceCheckbox()) + await flush() + expect(rowNames()).toEqual(previewOrder) + + click(button('Update 3 skills')) + expect(rowNames()).toEqual(previewOrder) + await act(async () => { + execution.resolve(doneResult(forced)) + await execution.promise + }) + expect(rowNames()).toEqual(previewOrder) + + mocks.plan = null + render() + expect(rowNames()).toEqual(previewOrder) + mocks.plan = planOf([ + sharedEntry('computer-use', 'keep'), + sharedEntry('orca-cli', 'keep'), + sharedEntry('agent-discord', 'leave', { reason: 'unwritable' }), + sharedEntry('orchestration', 'keep') + ]) + skillsApi.bundledPlan.mockResolvedValueOnce(mocks.plan) + render() + await flush() + expect(rowNames()).toEqual(previewOrder) + }) + + it('reports a location that changed since review as needing attention', async () => { + const plan = planOf([sharedEntry('orca-cli', 'update')]) + mocks.plan = plan + skillsApi.bundledExecute.mockResolvedValueOnce({ + scope: 'global', + skills: [{ name: 'orca-cli', status: 'changed', ops: [] }] + }) + render() + + click(button('Update 1 skill')) + await flush() + + expect(container.textContent).toContain('Some skills need attention') + expect(getSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET)).toMatchObject({ + state: 'done', + summary: { ok: false, exitCode: 1 } + }) + }) + + it('drops a completion for a run that was reset while it was in flight', async () => { + mocks.plan = planOf([sharedEntry('orca-cli', 'update')]) + const execution = deferred() + skillsApi.bundledExecute.mockReturnValueOnce(execution.promise) + render() + + click(button('Update 1 skill')) + expect(getSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET).state).toBe('running') + _resetSkillUpdateRunStore() + + await act(async () => { + execution.resolve(doneResult(planOf([sharedEntry('orca-cli', 'update')]))) + await execution.promise + }) + expect(getSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET).state).toBe('idle') + expect(mocks.notifyChanged).not.toHaveBeenCalled() + }) + + it('shows the error when the request itself fails', async () => { + mocks.plan = planOf([sharedEntry('orca-cli', 'update')]) + skillsApi.bundledExecute.mockRejectedValueOnce(new Error('ipc closed')) + render() + + click(button('Update 1 skill')) + await flush() + + expect(container.textContent).toContain("The update didn't finish") + expect(container.textContent).toContain('ipc closed') + }) + + it('acknowledges a finished run on close', async () => { + const plan = planOf([sharedEntry('orca-cli', 'update')]) + mocks.plan = plan + skillsApi.bundledExecute.mockResolvedValueOnce(doneResult(plan)) + render() + click(button('Update 1 skill')) + await flush() + + click(button('Done')) + expect(getSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET).state).toBe('idle') + expect(container.querySelector('[data-dialog-open]')).toBeNull() + }) + + it.each(['edited', 'other'] as const)( + 'shows no available updates when only %s copies remain', + (reason) => { + mocks.plan = planOf([ + planEntry('orca-cli', [planOp('leave', EDITED_PATH, { reason, replaceable: true })]) + ]) + render() + expect(container.textContent).toContain('No updates available') + const headline = [...container.querySelectorAll('div')].find( + (element) => element.textContent === 'No updates available' + ) + expect(headline?.querySelector('svg')).toBeNull() + expect(container.textContent).not.toContain('left alone') } ) - // Why: Orca's own traversal bounds are not the user's to act on. Headlining them - // would put a permanent warning on any ordinary large plugin cache while every - // skill badge stayed green — the same unclearable amber, moved into the dialog. - it('lists a traversal bound without headlining it', async () => { - mocks.inventory = { - schemaVersion: 1, - installations: [placement('orca-cli', { status: 'current' })], - eligibleUpdateNames: [], - scanIssues: [ - { - rootId: 'codex-plugin-cache', - sourceLabel: 'Codex plugin cache', - path: '/home/.codex/plugins/cache/vendor/deep', - reason: 'depth-limit', - errorCode: null - } - ], - scannedAt: 2 - } - await renderDialog() - await openViaRequest() + it.each([1, 2])( + 'counts %i current skills beside skipped copies without repeating a footer', + (count) => { + mocks.plan = planOf([ + planEntry('orca-cli', [planOp('leave', EDITED_PATH, { reason: 'edited' })]), + ...['orchestration', 'orca-linear'].slice(0, count).map((name) => sharedEntry(name, 'keep')) + ]) + render() - expect(container?.textContent).toContain('All installed Orca skills are up to date.') - expect(container?.textContent).not.toContain( - 'Orca could not finish checking plugin-managed skills.' - ) - expect(container?.textContent).toContain('/home/.codex/plugins/cache/vendor/deep') - expect(container?.textContent).toContain('scan depth limit') + const headline = `${count} ${count === 1 ? 'skill is' : 'skills are'} up to date` + expect(container.textContent?.split(headline)).toHaveLength(2) + expect(container.textContent).not.toContain(`${headline}.`) + expect(container.querySelector('.lucide-circle-check')).not.toBeNull() + expect(container.querySelector('.lucide-triangle-alert')).toBeNull() + click(button('Show folders')) + expect(container.textContent?.split(headline)).toHaveLength(2) + } + ) + + it('offers no Update when nothing would change', () => { + mocks.plan = planOf([sharedEntry('orca-cli', 'keep')]) + render() + + expect(container.textContent).toContain('All installed Orca skills are up to date.') + expect( + [...container.querySelectorAll('button')].some((candidate) => + candidate.textContent?.startsWith('Update ') + ) + ).toBe(false) }) }) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx index c852b0f4979..1e29b547de0 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -1,15 +1,10 @@ -import { useMemo, useRef, useState, useSyncExternalStore } from 'react' -import { AlertTriangle, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw } from 'lucide-react' -import { - canonicalizeSkillUpdateNames, - isSkillScanIssueNeedingAttention, - isSkillScanIssueTruncatingScan, - type SkillFreshnessInventory -} from '../../../../shared/skill-freshness' +import { useState, useSyncExternalStore } from 'react' +import { AlertTriangle, CheckCircle2, Loader2, RefreshCw } from 'lucide-react' +import type { BundledSkillOutcome, BundledSkillPlan } from '../../../../shared/bundled-skill-plan' +import { summarizeBundledSkillPlan } from '../../../../shared/bundled-skill-summary' import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' -import { buildAgentFeatureSkillInstallCommand } from '@/lib/agent-feature-install-commands' import { translate } from '@/i18n/i18n' import { Button } from '@/components/ui/button' import { @@ -19,259 +14,257 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { TooltipProvider } from '@/components/ui/tooltip' -import { groupSkillFreshness } from './skill-freshness-grouping' -import { useSkillUpdateSelection } from './use-skill-update-selection' -import { SkillFreshnessScanIssues } from './skill-freshness-scan-issues' -import { SkillUpdateRow } from './SkillUpdateRow' -import { - SummaryHeadline, - summarizeInventory, - updateActionLabel -} from './skill-freshness-summary-headline' +import type { SkillRowPhase } from './SkillUpdateRow' +import { SkillUpdateRows } from './SkillUpdateRows' +import { SummaryHeadline } from './skill-freshness-summary-headline' +import { updateActionLabel, updatedSkillsLabel } from './skill-plan-op-copy' import { acknowledgeSkillUpdateRun, - cancelSkillUpdateRun, + LOCAL_SKILL_UPDATE_TARGET, startSkillUpdateRun, - useSkillUpdateRun + useSkillUpdateRun, + type SkillUpdateRun } from './skill-update-run-store' import { consumeSkillFreshnessUpdateDialogRequest, getSkillFreshnessUpdateDialogRequest, subscribeSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' +import { useReviewedSkillPlan } from './use-reviewed-skill-plan' -function RunLog({ output }: { output: string }): React.JSX.Element | null { - if (!output.trim()) { - return null +function RunHeadline({ run }: { run: SkillUpdateRun }): React.JSX.Element | null { + if (run.state === 'running') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.progressAria', + 'Updating skills' + )} +
+ ) } + if (run.state === 'done' && run.summary.ok && !run.needsAttention) { + return ( +
+ + {updatedSkillsLabel(run.summary)} +
+ ) + } + if (run.state === 'done') { + return ( +
+ + {run.summary.ok + ? translate('auto.components.skills.skillPlanOpCopy.needsAttention', 'Needs attention') + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.finishedWithProblems', + 'Some skills need attention' + )} +
+ ) + } + if (run.state === 'error') { + return ( +
+
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.errorTitle', + "The update didn't finish" + )} +
+

+ {run.message} +

+
+ ) + } + return null +} + +function skillRowPhase(run: SkillUpdateRun): SkillRowPhase { + return run.state === 'idle' ? 'plan' : run.state === 'running' ? 'pending' : 'result' +} + +function runOutcomes(run: SkillUpdateRun): ReadonlyMap { + return new Map( + run.state === 'done' ? run.result.skills.map((outcome) => [outcome.name, outcome]) : [] + ) +} + +/** + * Why: settling a run re-plans every skills surface, and the plan is null while that re-reads + * the disk. Holding the last plan keeps the rows on screen as the result appears; only the live + * plan can authorize Update. + */ +function useHeldSkillPlan(plan: BundledSkillPlan | null): [BundledSkillPlan | null, () => void] { + const [heldPlan, setHeldPlan] = useState(null) + if (plan && plan !== heldPlan) { + setHeldPlan(plan) + } + return [plan ?? heldPlan, () => setHeldPlan(null)] +} + +/** A run's progress or result once Update starts; until then the plan's headline or error. */ +function DialogHeadline({ + run, + error, + plan +}: { + run: SkillUpdateRun + error: string | null + plan: BundledSkillPlan | null +}): React.JSX.Element | null { + if (run.state !== 'idle') { + return + } + return error ? ( +

{error}

+ ) : ( + + ) +} + +/** Re-check, Close or Done, and Update; `updatePlan` is null when there is nothing to update. */ +function SkillUpdateFooter({ + showResult, + isRunning, + checking, + updatePlan, + canUpdate, + onRecheck, + onClose, + onUpdate +}: { + showResult: boolean + isRunning: boolean + checking: boolean + updatePlan: BundledSkillPlan | null + canUpdate: boolean + onRecheck: () => void + onClose: () => void + onUpdate: () => void +}): React.JSX.Element { return ( - - + + {showResult ? ( + + ) : ( - - - {/* Displayed verbatim, never parsed — `skills add` has no --json. */} -
-          {output.trim()}
-        
-
-
+ )} +
+ + {!showResult && updatePlan ? ( + + ) : null} +
+ ) } export function SkillFreshnessUpdateDialog(): React.JSX.Element { const activeSkillRuntime = useActiveProjectSkillRuntime() const state = useSkillFreshness(activeSkillRuntime.canUseLocalSkillFreshness) - const run = useSkillUpdateRun() + const run = useSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET) const open = useSyncExternalStore( subscribeSkillFreshnessUpdateDialog, getSkillFreshnessUpdateDialogRequest, getSkillFreshnessUpdateDialogRequest ) - const [copied, setCopied] = useState(false) + const [force, setForce] = useState([]) + const reviewed = useReviewedSkillPlan(state.plan, force) - // Why: settling a run notifies every skills surface, and that refresh nulls the - // inventory *synchronously* while it re-hashes each package on disk. Rendering - // rows off the last good scan keeps them on screen through that window instead - // of blanking the dialog at the exact moment the result appears. Eligibility - // below still reads the live snapshot, so nothing is authorized off stale bytes. - const lastInventoryRef = useRef(null) - if (state.inventory) { - lastInventoryRef.current = state.inventory - } - const inventory = state.inventory ?? (state.loading ? lastInventoryRef.current : null) - // Display only. The button stays disabled until a live scan lands, so a re-scan in - // flight can never authorize work — but it keeps its place and its label instead of - // vanishing and reflowing the footer every time one runs. - const displayEligibleCount = inventory?.eligibleUpdateNames.length ?? 0 - const isRunning = run.state === 'running' - // The kill sweep can take seconds; without this the Stop button sits enabled - // and inert, which reads as broken. - const isStopping = run.state === 'running' && run.stopping === true - const showResult = run.state === 'success' || run.state === 'error' - // Keyed on the names themselves: every captured output chunk republishes the - // run, and regrouping the whole inventory per chunk would re-render each row. - const runNamesKey = run.state === 'idle' ? '' : run.names.join('\n') - const runNames = useMemo(() => (runNamesKey ? runNamesKey.split('\n') : []), [runNamesKey]) - const groups = useMemo( - () => - inventory - ? groupSkillFreshness(inventory.installations, inventory.eligibleUpdateNames, runNames) - : [], - [inventory, runNames] - ) - const selection = useSkillUpdateSelection(groups) - const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update') - const blockedCount = groups.filter((group) => group.status === 'cannot-update').length - // Retained: the list keeps the last known folders on screen through a re-scan, the - // same way the rows above stay put rather than blanking. - const scanIssues = inventory?.scanIssues ?? [] - // Why: the headline reads the LIVE snapshot, not the retained one — the two - // disagree for the whole loading window, and pairing a retained "eligible" with - // a live count of 0 renders "0 updates available" over rows badged "Update - // available". Live means it says "Checking…" over the rows it kept on screen. - const summaryKind = summarizeInventory( - state.inventory, - selection.ownedCount, - hasBlockedGroup, - (state.inventory?.scanIssues ?? []).some( - (issue) => isSkillScanIssueNeedingAttention(issue) || isSkillScanIssueTruncatingScan(issue) - ) - ) - - // Why: one row list for every state. The rows are identical objects across the - // transition, so pressing Update changes each row's leading icon in place - // instead of swapping the dialog's body for a different component. - const failedNamesKey = run.state === 'error' ? run.failedNames.join('\n') : '' - const rows = useMemo(() => { - const failed = new Set(failedNamesKey ? failedNamesKey.split('\n') : []) - const inRun = new Set(runNames) - return groups.map((group) => { - if (inRun.has(group.name)) { - if (isRunning) { - return { group, state: 'pending' as const } - } - return { group, state: failed.has(group.name) ? ('failed' as const) : ('done' as const) } - } - return { - group, - state: group.status === 'cannot-update' ? ('blocked' as const) : ('available' as const) - } - }) - }, [groups, isRunning, failedNamesKey, runNames]) + const [shownPlan, releaseHeldPlan] = useHeldSkillPlan(reviewed.plan) + const shownSummary = shownPlan ? summarizeBundledSkillPlan(shownPlan) : null + const [showLocations, setShowLocations] = useState(false) + const phase = skillRowPhase(run) + const isRunning = phase === 'pending' + const showResult = phase === 'result' + const needsAttention = run.state === 'done' && run.needsAttention + const livePlan = reviewed.plan + const canUpdate = + !isRunning && + (!showResult || needsAttention) && + livePlan !== null && + !reviewed.loading && + summarizeBundledSkillPlan(livePlan).hasWork const handleOpenChange = (next: boolean): void => { if (next) { return } - // Why: closing never cancels. The run is owned by main and keeps going; the - // status-bar segment carries it from here. + // Why: closing never cancels; the status-bar segment carries a running update. consumeSkillFreshnessUpdateDialogRequest() - setCopied(false) - selection.reset() - // Don't carry a finished session's rows into the next open — but a live run - // keeps its own, or reopening from the status segment mid-run would land on - // an empty list while the close's own re-scan is still reading disk. + setForce([]) + setShowLocations(false) if (run.state === 'idle') { - lastInventoryRef.current = null + releaseHeldPlan() } if (showResult) { - void acknowledgeSkillUpdateRun() + acknowledgeSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET) } notifyInstalledAgentSkillsChanged() } - const handleUpdate = (names: readonly string[]): void => { - void startSkillUpdateRun(names) - } - - const handleCopyCommand = (): void => { - const names = run.state === 'error' ? canonicalizeSkillUpdateNames(run.failedNames) : null - if (!names) { + const handleUpdate = (): void => { + if (!livePlan) { return } - // Clipboard writes reject on a denied permission or an unfocused document; - // without this the button just never flips to "Copied". - void navigator.clipboard - .writeText(buildAgentFeatureSkillInstallCommand(names)) - .then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }) - .catch((error: unknown) => { - console.error('Failed to copy skill update command', error) - }) + void startSkillUpdateRun({ + targetKey: LOCAL_SKILL_UPDATE_TARGET, + // Why: only skills with work describe the in-flight run. + names: summarizeBundledSkillPlan(livePlan) + .skills.filter((skill) => skill.state !== 'current') + .map((skill) => skill.name), + planDigest: livePlan.planDigest, + force + }) } - const headline = ((): React.JSX.Element => { - if (isStopping) { - // Why: no "keeps running in the background" line here — after Stop that is - // the opposite of what is happening. - return ( -
- - {translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.stoppingHeadline', - 'Stopping the update…' - )} -
- ) - } - if (isRunning) { - return ( -
-
- - {run.names.length === 1 - ? translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.runningOne', - 'Updating 1 skill…' - ) - : translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.runningMany', - 'Updating {{value0}} skills…', - { value0: run.names.length } - )} -
-

- {translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.runningDescription', - 'You can close this window — it keeps running in the background.' - )} -

-
- ) - } - if (run.state === 'success') { - return ( -
- - {run.names.length === 1 - ? translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.updatedOne', - 'Updated 1 skill' - ) - : translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.updatedMany', - 'Updated {{value0}} skills', - { value0: run.names.length } - )} -
- ) - } - if (run.state === 'error') { - const updated = run.names.length - run.failedNames.length - return ( -
- - {translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.updatedPartial', - 'Updated {{value0}} of {{value1}} skills', - { value0: updated, value1: run.names.length } - )} -
- ) - } - return ( - - ) - })() + const handleRecheck = (): void => { + acknowledgeSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET) + setForce([]) + void state.refresh() + } + const replace = { + isReplaced: (path: string) => force.includes(path), + onReplaceChange: (path: string, next: boolean) => { + // Why: a choice made on the result starts a new review of the re-planned skills. + if (showResult) { + acknowledgeSkillUpdateRun(LOCAL_SKILL_UPDATE_TARGET) + } + setForce((previous) => + next ? [...new Set([...previous, path])].sort() : previous.filter((item) => item !== path) + ) + } + } + + const error = reviewed.error ?? state.error return ( - {state.error && !isRunning && !showResult ? ( -

{state.error}

- ) : ( - headline - )} + - {isRunning ? ( - // Indeterminate on purpose: the CLI reports no parseable progress. -
-
-
+ {shownSummary && shownSummary.skills.length > 0 ? ( + setShowLocations((previous) => !previous)} + /> ) : null} - {rows.length > 0 ? ( -
- - {rows.map((row) => ( - - ))} - -
- ) : null} - - {/* Why: folders, not skills — a plugin path Orca could not read says nothing - about which skill lives there, so it cannot be a row above. */} - {scanIssues.length > 0 ? ( -
- -
- ) : null} - - {run.state === 'error' ? ( -
-

- {translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.errorTitle', - "The update didn't finish" - )} -

-

- {run.message} -

-
- {/* Retry what actually failed, not the live eligibility list — - the settling re-scan empties that for the whole window this - button is on screen, so retrying it would be a silent no-op. */} - - -
-
- ) : null} - - {isRunning || showResult ? : null} - - - {isRunning ? ( - // The terminal used to be the escape hatch for a stalled update; - // without it a wedged npx would leave restarting Orca as the only way out. - - ) : showResult ? ( - - ) : ( - - )} -
- - {!showResult && displayEligibleCount > 0 ? ( - - ) : null} -
-
+ handleOpenChange(false)} + onUpdate={handleUpdate} + />
) diff --git a/src/renderer/src/components/skills/SkillUpdateRow.test.tsx b/src/renderer/src/components/skills/SkillUpdateRow.test.tsx index bacc934c590..d1cbdb9974b 100644 --- a/src/renderer/src/components/skills/SkillUpdateRow.test.tsx +++ b/src/renderer/src/components/skills/SkillUpdateRow.test.tsx @@ -1,52 +1,30 @@ // @vitest-environment happy-dom -import { act, type ReactNode } from 'react' +import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SkillFreshnessGroupModel } from './skill-freshness-grouping' +import type { BundledSkillOp, BundledSkillOutcome } from '../../../../shared/bundled-skill-plan' +import { + BUNDLED_SKILL_PLAN_OP_TEXT, + summarizeBundledSkillPlan +} from '../../../../shared/bundled-skill-summary' +import { TooltipProvider } from '@/components/ui/tooltip' import { SkillUpdateRow } from './SkillUpdateRow' - -// Why: render tooltip content inline so the copy it carries can be asserted. -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children?: ReactNode }) => ( -
{children}
- ) -})) +import { locationStateText } from './skill-plan-op-copy' +import { bundledSkillLocation } from '../../../../shared/bundled-skill-locations' +import { planEntry, planOf, planOp } from './bundled-skill-plan-fixtures.test-support' let root: Root | null = null let container: HTMLDivElement | null = null -function group(overrides: Partial = {}): SkillFreshnessGroupModel { - return { - name: 'computer-use', - status: 'update-available', - orcaOwned: false, - lockSource: 'acme/agent-skills', - locations: [ - { - id: 'location', - path: '/home/.agents/skills/computer-use', - chip: null, - participatesInGlobalFreshness: true - } - ], - ...overrides - } -} - -async function render(element: React.JSX.Element): Promise { +async function render(element: React.JSX.Element): Promise { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render(element) + root?.render({element}) }) -} - -function tooltipText(): string | undefined { - return container?.querySelector('[data-tooltip-content]')?.textContent ?? undefined + return container } afterEach(async () => { @@ -58,61 +36,225 @@ afterEach(async () => { container = null }) +const SHARED = '/home/u/.agents/skills/orca-cli' +const CLAUDE = '/home/u/.claude/skills/orca-cli' + +function skillOf(ops: BundledSkillOp[]) { + return summarizeBundledSkillPlan(planOf([planEntry('orca-cli', ops)])).skills[0] +} + +function row(view: HTMLElement): string { + return view.querySelector('[data-skill-row="orca-cli"]')?.textContent ?? '' +} + describe('SkillUpdateRow', () => { - it('labels a copy from another source quietly and names that source', async () => { - await render() - - expect(container?.textContent).toContain('Not from Orca') - expect(container?.textContent).not.toContain('Update available') - expect(tooltipText()).toBe( - 'Installed from acme/agent-skills. Selecting it replaces it with Orca’s version.' - ) - // Why: the source is only in the tooltip, so keyboard focus must be able to open it. - const label = Array.from(container?.querySelectorAll('[tabindex="0"]') ?? []).find((node) => - node.textContent?.includes('Not from Orca') - ) - expect(label).toBeDefined() + it.each([ + ['update', 'Update'], + ['restore', 'Repair'], + ['write', 'Install'], + ['copy', 'Install'], + ['link', 'Link'], + ['replace', 'Replace'] + ] as const)('uses the short %s action in the renderer and CLI', (kind, label) => { + const location = bundledSkillLocation(planOp(kind, CLAUDE), '/home/u') + expect(locationStateText(location)).toBe(label) + expect(BUNDLED_SKILL_PLAN_OP_TEXT[kind]).toBe(label) }) - it('says there is no install record when the lock has no entry', async () => { - await render() - - expect(tooltipText()).toBe( - 'No Orca install record for this copy. Selecting it replaces it with Orca’s version.' + it.each([ + ['update', 'Update available'], + ['restore', 'Repair available'], + ['write', 'Install available'], + ['copy', 'Install available'], + ['link', 'Install available'], + ['replace', 'Replace'] + ] as const)('shows the available %s row status', async (kind, label) => { + const view = await render( + ) + expect(row(view)).toBe(`orca-cli${label}`) }) - it('keeps the update badge for Orca’s own skill', async () => { - await render( + it('reports an unknown reason without claiming the copy was updated', async () => { + const view = await render( + ) + expect(row(view)).toBe('orca-cliUpdate skipped: This copy was kept as it is') + }) + + it.each([ + ['edited', 'Skill edited outside Orca'], + ['other', 'A different skill uses this name'], + ['unwritable', "Orca can't write to this folder"], + ['unreadable', "Orca can't read every file"], + ['busy', 'Another install is still running'], + ['shared-not-current', "The shared copy isn't Orca's"] + ] as const)( + 'explains %s copies without repeating the status on folder lines', + async (reason, text) => { + const view = await render( + + ) + expect(row(view)).toBe( + `orca-cliUpdate skipped: ${text}Claude Code · ~/.claude/skills/orca-cli` + ) + } + ) + + it('says what Update does, and lists locations only on request', async () => { + const skill = skillOf([planOp('update', SHARED), planOp('keep', CLAUDE)]) + expect( + row(await render()) + ).toBe('orca-cliUpdate available') + await act(async () => root?.unmount()) + const shown = row(await render()) + expect(shown).toContain('Shared folder · ~/.agents/skills/orca-cli') + expect(shown).toContain('Claude Code · ~/.claude/skills/orca-cli') + expect(shown.match(/Update/gu)).toHaveLength(1) + expect(shown).not.toContain('No change') + }) + + it.each(['plan', 'result'] as const)( + 'shows identical skip reasons once per skill in the %s phase, retaining every folder', + async (phase) => { + const cursor = '/home/u/.cursor/skills/orca-cli' + const ops = [ + planOp('leave', SHARED, { reason: 'edited' }), + planOp('leave', CLAUDE, { reason: 'shared-not-current' }), + planOp('leave', cursor, { reason: 'shared-not-current', agents: ['cursor'] }) + ] + const view = await render( + ({ ...op, status: 'done' })) + }} + showLocations + /> + ) + + expect(row(view).split("Update skipped: The shared copy isn't Orca's")).toHaveLength(2) + expect(row(view).split('Update skipped: Skill edited outside Orca')).toHaveLength(2) + for (const path of [SHARED, CLAUDE, cursor]) { + expect(view.querySelector(`.font-mono[title="${path}"]`)).not.toBeNull() + } + } + ) + + it('keeps separate replacement choices for folders with the same skip reason', async () => { + const onReplaceChange = vi.fn() + const view = await render( + + planOp('leave', path, { reason: 'edited', replaceable: true }) + ) + )} + phase="plan" + showLocations={false} + replace={{ isReplaced: () => false, onReplaceChange }} /> ) - expect(container?.textContent).toContain('Update available') - expect(container?.textContent).not.toContain('Not from Orca') + expect(row(view).split('Update skipped: Skill edited outside Orca')).toHaveLength(2) + const checkboxes = view.querySelectorAll('[role="checkbox"]') + expect(checkboxes).toHaveLength(2) + await act(async () => { + checkboxes[1].dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onReplaceChange).toHaveBeenCalledWith(CLAUDE, true) }) - it('renders a checkbox only while the user is choosing', async () => { - const onCheckedChange = vi.fn() - await render( + it('reports a copy skipped without an agent prefix and offers Replace only where it is allowed', async () => { + const onReplaceChange = vi.fn() + const view = await render( false, onReplaceChange }} /> ) - const checkbox = container?.querySelector('[role="checkbox"]') - expect(checkbox?.getAttribute('aria-label')).toBe('Include computer-use') + expect(row(view)).toContain("Update skipped: Orca can't write to this folder") + expect(row(view)).toContain('Update skipped: Skill edited outside Orca') + expect(row(view)).not.toContain('Claude Code:') + const checkboxes = view.querySelectorAll('[role="checkbox"]') + expect(checkboxes).toHaveLength(1) await act(async () => { - checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + checkboxes[0].dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(onCheckedChange).toHaveBeenCalledWith(true) + expect(onReplaceChange).toHaveBeenCalledWith(CLAUDE, true) + }) - await act(async () => { - root?.render() - }) - expect(container?.querySelector('[role="checkbox"]')).toBeNull() + it('shows the outcome after a run, keeping what was left alone', async () => { + const outcome: BundledSkillOutcome = { + name: 'orca-cli', + status: 'ran', + ops: [ + { path: SHARED, kind: 'update', status: 'done' }, + { path: CLAUDE, kind: 'leave', status: 'done', reason: 'edited' } + ] + } + const view = await render( + + ) + expect(row(view)).toBe('orca-cliUpdatedUpdate skipped: Skill edited outside Orca') + }) + + it.each<{ + ops: BundledSkillOutcome['ops'] + text: string + }>([ + { + ops: [{ path: CLAUDE, kind: 'replace', status: 'done' }], + text: 'Replaced · Your copy was saved as a backup' + }, + { + ops: [{ path: CLAUDE, kind: 'update', status: 'done' }], + text: 'Updated' + }, + { + ops: [{ path: CLAUDE, kind: 'replace', status: 'skipped' }], + text: 'Needs attention' + }, + { + ops: [ + { path: CLAUDE, kind: 'replace', status: 'done' }, + { path: SHARED, kind: 'update', status: 'failed' } + ], + text: 'Needs attention' + } + ])('shows $text from the executed operations after the plan refreshes', async ({ ops, text }) => { + const view = await render( + + ) + expect(row(view)).toBe(`orca-cli${text}`) }) }) diff --git a/src/renderer/src/components/skills/SkillUpdateRow.tsx b/src/renderer/src/components/skills/SkillUpdateRow.tsx index db1abad6a46..638238f395b 100644 --- a/src/renderer/src/components/skills/SkillUpdateRow.tsx +++ b/src/renderer/src/components/skills/SkillUpdateRow.tsx @@ -1,216 +1,143 @@ -import { AlertTriangle, CheckCircle2, ChevronDown, Circle, Info, XCircle } from 'lucide-react' -import type { SkillFreshnessGroupModel } from './skill-freshness-grouping' +import type { BundledSkillLocation } from '../../../../shared/bundled-skill-locations' +import type { BundledSkillOutcome } from '../../../../shared/bundled-skill-plan' +import { + bundledSkillOutcomeText, + type BundledSkillPlanSkillSummary +} from '../../../../shared/bundled-skill-summary' import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { chipLabel, chipTooltip } from './skill-location-chip-copy' -import { skippedReason } from './skill-freshness-skipped-reason' +import { + locationPlaceLabel, + locationStateText, + skillOutcomeText, + skillPlanHeadline +} from './skill-plan-op-copy' -export type SkillRowState = 'available' | 'blocked' | 'pending' | 'done' | 'failed' +export type SkillRowPhase = 'plan' | 'pending' | 'result' -/** - * Leading status glyph. Absent for `available` on purpose: an empty reserved box - * would just indent the name past a gap with nothing in it. - */ -function StateIcon({ state }: { state: SkillRowState }): React.JSX.Element | null { - switch (state) { - case 'done': - return - case 'failed': - return - case 'pending': - return - case 'blocked': - return - case 'available': - return null - } +export type SkillReplaceToggle = { + isReplaced: (path: string) => boolean + onReplaceChange: (path: string, replace: boolean) => void } -function notFromOrcaTooltip(lockSource: string | null): string { - return lockSource - ? translate( - 'auto.components.skills.SkillUpdateRow.notFromOrcaSourceTip', - 'Installed from {{value0}}. Selecting it replaces it with Orca’s version.', - { value0: lockSource } - ) - : translate( - 'auto.components.skills.SkillUpdateRow.notFromOrcaNoRecordTip', - 'No Orca install record for this copy. Selecting it replaces it with Orca’s version.' - ) -} - -/** Sits immediately right of the name, so the label reads as part of it. */ -function StateBadge({ - state, - group +/** A location Update leaves alone, or one the user chose to Replace. */ +function ChoiceLine({ + location, + reason, + replace }: { - state: SkillRowState - group: SkillFreshnessGroupModel + location: BundledSkillLocation + reason: string | null + replace?: SkillReplaceToggle }): React.JSX.Element | null { - if (state === 'blocked') { - return ( - - {translate('auto.components.skills.SkillFreshnessRow.statusCantUpdate', 'Skipped')} - - ) + if (!reason && !(replace && location.replaceable)) { + return null } - if (state === 'available' && !group.orcaOwned) { - // Why: quiet on purpose; the copy is offered, not recommended. - return ( - - - - {translate('auto.components.skills.SkillUpdateRow.notFromOrca', 'Not from Orca')} - - - - - {notFromOrcaTooltip(group.lockSource)} - - - ) - } - if (state === 'available') { - return ( - - {translate( - 'auto.components.skills.SkillFreshnessRow.statusUpdateAvailable', - 'Update available' - )} - - ) - } - // Why: once a run owns the row the leading glyph carries the state. A stale - // "Update available" beside a green check would contradict it. - return null -} - -/** - * One skill in the update dialog, used unchanged in every state. - * - * The header keeps identical geometry from "update available" through running to - * the result — the row keeps its place and its right edge — so pressing Update - * doesn't replace the dialog's layout with a different one. Locations live - * behind a per-skill disclosure instead of being dumped inline, because a skill - * with several plugin-cache copies otherwise buries the actions. - */ -export function SkillUpdateRow({ - group, - state, - selection -}: { - group: SkillFreshnessGroupModel - state: SkillRowState - /** Present only while the user is choosing what to update. */ - selection?: { checked: boolean; onCheckedChange: (checked: boolean) => void } -}): React.JSX.Element { - const locationCount = group.locations.length return ( - -
- {/* Why: outside the trigger, so ticking a row never toggles its disclosure. */} - {selection ? ( +
+ {reason ? ( + + {reason} + + ) : null} + {replace && location.replaceable ? ( +
- - {state === 'failed' ? ( -

{translate( - 'auto.components.skills.SkillUpdateResultRows.stillOutdated', - 'Still out of date after the update ran.' + 'auto.components.skills.SkillUpdateRow.replaceWithOrca', + "Replace with Orca's copy (keeps a backup)" )} -

+ ) : null} +
+ ) +} - {/* Why: outside the disclosure, alongside the failure line. The reason is - the whole point of a skipped row, so it must not depend on a click — - nor on a mount-time `defaultOpen` that a re-scan can't re-fire. */} - {state === 'blocked' ? ( -

- {skippedReason(group.locations)} -

- ) : null} +function LocationTable({ + locations +}: { + locations: readonly BundledSkillLocation[] +}): React.JSX.Element { + return ( +
+ {locations.map((location) => ( +
+ {locationPlaceLabel(location)} + {' · '} + + {location.displayPath} + +
+ ))} +
+ ) +} - -
- {group.locations.map((location) => ( -
- {/* Why: plugin-cache paths nest arbitrarily deep. Without an explicit - shrink basis the unbreakable string sets the dialog's width and - pushes the footer actions off-screen. */} - - {location.path} - - {location.chip ? ( - - - - {chipLabel(location.chip)} - - - - {chipTooltip(location.chip)} - - - ) : null} -
- ))} -
-
-
+/** + * One skill in the update dialog: what Update does to it, the locations it leaves for the + * user and, on request, every folder it planned. + */ +export function SkillUpdateRow({ + skill, + phase, + outcome, + replace, + showLocations +}: { + skill: BundledSkillPlanSkillSummary + phase: SkillRowPhase + outcome?: BundledSkillOutcome + replace?: SkillReplaceToggle + showLocations: boolean +}): React.JSX.Element { + const replacement = skill.locations.find((location) => location.state === 'replace') + const headline = + phase === 'pending' + ? translate('auto.components.skills.SkillFreshnessUpdateDialog.updating', 'Updating…') + : phase === 'result' && outcome + ? // Why: a skill the run only reports on shows its left-alone lines, not "No change". + bundledSkillOutcomeText(outcome) === 'No change' && + outcome.ops.some((op) => op.kind === 'leave') + ? null + : skillOutcomeText(outcome) + : replacement + ? locationStateText(replacement) + : skillPlanHeadline(skill.state) + const choices = skill.locations.filter( + (location) => location.state === 'leave' || (phase !== 'result' && location.state === 'replace') + ) + const seenReasons = new Set() + return ( +
+ + {skill.name} + +
+ {headline ? {headline} : null} + {choices.map((location) => { + const reason = location.state === 'leave' ? locationStateText(location) : null + const uniqueReason = reason && !seenReasons.has(reason) ? reason : null + if (reason) { + seenReasons.add(reason) + } + return ( + + ) + })} + {showLocations ? : null} +
+
) } diff --git a/src/renderer/src/components/skills/SkillUpdateRows.tsx b/src/renderer/src/components/skills/SkillUpdateRows.tsx new file mode 100644 index 00000000000..0a3fbeec06e --- /dev/null +++ b/src/renderer/src/components/skills/SkillUpdateRows.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react' +import { ChevronDown, ChevronUp } from 'lucide-react' +import type { BundledSkillOutcome, BundledSkillPlan } from '../../../../shared/bundled-skill-plan' +import { + bundledSkillOutcomeText, + bundledSkillPlanState +} from '../../../../shared/bundled-skill-summary' +import type { + BundledSkillPlanSkillSummary, + BundledSkillPlanState +} from '../../../../shared/bundled-skill-summary' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { TooltipProvider } from '@/components/ui/tooltip' +import { SkillUpdateRow, type SkillReplaceToggle, type SkillRowPhase } from './SkillUpdateRow' + +// Why: what Update changes reads first, then what it leaves for the user. +const ROW_ORDER: readonly BundledSkillPlanState[] = [ + 'repair', + 'update', + 'install', + 'attention', + 'current' +] + +/** A finished run lists what it changed and what it left; untouched skills stay off. */ +function resultShowsRow(outcome: BundledSkillOutcome | undefined): boolean { + return Boolean( + outcome && + (bundledSkillOutcomeText(outcome) !== 'No change' || + outcome.ops.some((op) => op.kind === 'leave')) + ) +} + +function UpToDateLine({ + skills +}: { + skills: readonly BundledSkillPlanSkillSummary[] +}): React.JSX.Element | null { + const upToDate = skills.filter((skill) => skill.upToDate) + if (upToDate.length === 0) { + return null + } + return ( +

+ {upToDate.length === 1 + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.upToDateOne', + '{{value0}} is up to date.', + { value0: upToDate[0].name } + ) + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.upToDateMany', + '{{value0}} skills are up to date.', + { value0: upToDate.length } + )} +

+ ) +} + +/** The skill rows plus, while reviewing, the up-to-date line and the locations toggle. */ +export function SkillUpdateRows({ + skills, + unforcedPlan, + hasWork, + phase, + outcomes, + replace, + showLocations, + onToggleLocations +}: { + skills: readonly BundledSkillPlanSkillSummary[] + unforcedPlan: BundledSkillPlan | null + hasWork: boolean + phase: SkillRowPhase + outcomes: ReadonlyMap + replace: SkillReplaceToggle + showLocations: boolean + onToggleLocations: () => void +}): React.JSX.Element { + const unforcedStates = new Map( + unforcedPlan?.skills.map((entry) => [entry.name, bundledSkillPlanState(entry)]) + ) + const plannedSkills = [...skills].sort( + (left, right) => + ROW_ORDER.indexOf(unforcedStates.get(left.name) ?? left.state) - + ROW_ORDER.indexOf(unforcedStates.get(right.name) ?? right.state) + ) + const [previewOrder, setPreviewOrder] = useState([]) + if ( + phase === 'plan' && + (previewOrder.length !== plannedSkills.length || + plannedSkills.some((skill, index) => skill.name !== previewOrder[index])) + ) { + setPreviewOrder(plannedSkills.map((skill) => skill.name)) + } + // Why: completion refreshes the plan, but result rows must keep their preview positions. + const previewRanks = new Map(previewOrder.map((name, index) => [name, index])) + const orderedSkills = + phase === 'plan' + ? plannedSkills + : [...skills].sort( + (left, right) => + (previewRanks.get(left.name) ?? previewOrder.length) - + (previewRanks.get(right.name) ?? previewOrder.length) + ) + return ( +
+ + {orderedSkills + .filter((skill) => + phase === 'result' + ? resultShowsRow(outcomes.get(skill.name)) + : showLocations || !skill.upToDate + ) + .map((skill) => ( + + ))} + + {phase === 'plan' ? ( +
+ {!showLocations && hasWork ? : null} + +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/skills/bundled-skill-plan-fixtures.test-support.ts b/src/renderer/src/components/skills/bundled-skill-plan-fixtures.test-support.ts new file mode 100644 index 00000000000..74c7520e571 --- /dev/null +++ b/src/renderer/src/components/skills/bundled-skill-plan-fixtures.test-support.ts @@ -0,0 +1,61 @@ +import type { + BundledSkillInstallResult, + BundledSkillOp, + BundledSkillPlan, + BundledSkillPlanEntry +} from '../../../../shared/bundled-skill-plan' + +const DIGEST = 'a'.repeat(64) + +export function planOp( + kind: BundledSkillOp['kind'], + path: string, + extra: Partial = {} +): BundledSkillOp { + return { + kind, + path, + role: path.includes('/.agents/') ? 'shared' : 'agent', + agents: path.includes('/.claude/') ? ['claude'] : [], + observed: { kind: 'absent' }, + cleanup: [], + ...extra + } +} + +export function planEntry(name: string, ops: BundledSkillOp[]): BundledSkillPlanEntry { + return { name, digest: DIGEST, ops } +} + +/** One skill per name; each entry's shared op has the given kind. */ +export function planOf( + entries: readonly BundledSkillPlanEntry[], + planDigest = 'b'.repeat(64) +): BundledSkillPlan { + return { scope: 'global', homeDirectory: '/home/u', skills: entries, planDigest } +} + +export function sharedEntry( + name: string, + kind: BundledSkillOp['kind'], + extra: Partial = {} +): BundledSkillPlanEntry { + return planEntry(name, [planOp(kind, `/home/u/.agents/skills/${name}`, extra)]) +} + +/** Every op of the plan ran as planned. */ +export function doneResult(plan: BundledSkillPlan): BundledSkillInstallResult { + return { + scope: plan.scope, + skills: plan.skills.map((entry) => ({ + name: entry.name, + status: 'ran', + ops: entry.ops.map((op) => ({ + path: op.path, + kind: op.kind, + status: 'done', + ...(op.reason ? { reason: op.reason } : {}) + })) + })) + } +} diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.test.ts b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts deleted file mode 100644 index f9503b53ac5..00000000000 --- a/src/renderer/src/components/skills/skill-freshness-grouping.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' -import { groupSkillFreshness } from './skill-freshness-grouping' - -function placement( - name: string, - overrides: Partial = {} -): SkillFreshnessInstallation { - return { - id: `${name}-${overrides.rootId ?? 'home-agents'}-${overrides.unresolvedPath ?? 'a'}`, - name, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: `/home/.agents/skills/${name}`, - resolvedPath: `/home/.agents/skills/${name}`, - physicalIdentity: `physical-${name}`, - topology: 'canonical-copy', - status: 'outdated', - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null, - ...overrides - } -} - -describe('groupSkillFreshness', () => { - it('marks an eligible outdated skill as update-available with one location', () => { - const groups = groupSkillFreshness([placement('orca-cli')], ['orca-cli']) - expect(groups).toHaveLength(1) - expect(groups[0]).toMatchObject({ - name: 'orca-cli', - status: 'update-available', - orcaOwned: true, - lockSource: 'stablyai/orca' - }) - expect(groups[0]?.locations).toEqual([ - { - id: expect.any(String), - path: '/home/.agents/skills/orca-cli', - chip: null, - participatesInGlobalFreshness: true - } - ]) - }) - - it('sorts Orca-owned skills first, then by name', () => { - const groups = groupSkillFreshness( - [ - placement('computer-use', { lockSource: 'acme/agent-skills' }), - placement('linear-tickets', { lockSource: null }), - placement('orchestration'), - placement('orca-cli', { lockSource: 'https://github.com/StablyAI/orca.git' }) - ], - ['computer-use', 'linear-tickets', 'orca-cli', 'orchestration'] - ) - expect(groups.map((group) => [group.name, group.orcaOwned])).toEqual([ - ['orca-cli', true], - ['orchestration', true], - ['computer-use', false], - ['linear-tickets', false] - ]) - expect(groups.find((group) => group.name === 'computer-use')?.lockSource).toBe( - 'acme/agent-skills' - ) - }) - - it('hides skills whose every copy is current', () => { - const groups = groupSkillFreshness( - [ - placement('orca-cli', { status: 'current' }), - placement('orca-cli', { status: 'current', topology: 'provider-alias' }) - ], - [] - ) - expect(groups).toEqual([]) - }) - - it('keeps a plugin-managed copy of a same-named skill out of the list', () => { - // Why: without a list of past releases a differing plugin copy cannot be told from - // another vendor's skill, and Orca never writes there. - const groups = groupSkillFreshness( - [placement('dataviz', { status: 'outdated', topology: 'plugin-cache' })], - [] - ) - expect(groups).toEqual([]) - }) - - it('lists a skill whose only fault cannot be fixed by the update, so Details explains it', () => { - const groups = groupSkillFreshness( - [ - placement('dataviz', { topology: 'independent-copy' }), - placement('linear-tickets', { status: 'inaccessible' }) - ], - [] - ) - expect(groups).toEqual([ - { - name: 'dataviz', - status: 'cannot-update', - orcaOwned: true, - lockSource: 'stablyai/orca', - locations: [ - { - id: expect.any(String), - path: '/home/.agents/skills/dataviz', - chip: 'duplicate', - participatesInGlobalFreshness: true - } - ] - }, - { - name: 'linear-tickets', - status: 'cannot-update', - orcaOwned: true, - lockSource: 'stablyai/orca', - locations: [ - { - id: expect.any(String), - path: '/home/.agents/skills/linear-tickets', - chip: 'inaccessible', - participatesInGlobalFreshness: true - } - ] - } - ]) - }) - - it('groups a blocked skill and flags the culprit location, not the main copy', () => { - const groups = groupSkillFreshness( - [ - placement('orchestration'), - placement('orchestration', { - rootId: 'home-claude', - unresolvedPath: '/home/.claude/skills/orchestration', - topology: 'external-link' - }) - ], - [] - ) - expect(groups).toHaveLength(1) - expect(groups[0]?.status).toBe('cannot-update') - // Why: the out-of-date main copy is bare; only the blocking copy carries a chip. - expect(groups[0]?.locations).toEqual([ - { - id: expect.any(String), - path: '/home/.agents/skills/orchestration', - chip: null, - participatesInGlobalFreshness: true - }, - { - id: expect.any(String), - path: '/home/.claude/skills/orchestration', - chip: 'external-link', - participatesInGlobalFreshness: true - } - ]) - }) - - it('blocks a skill whose only outdated copy is an unreachable duplicate', () => { - const groups = groupSkillFreshness( - [ - placement('orchestration', { status: 'current' }), - placement('orchestration', { - rootId: 'home-factory', - unresolvedPath: '/home/.factory/skills/orchestration', - topology: 'independent-copy' - }) - ], - [] - ) - expect(groups).toHaveLength(1) - expect(groups[0]?.status).toBe('cannot-update') - expect(groups[0]?.locations).toEqual([ - { - id: expect.any(String), - path: '/home/.agents/skills/orchestration', - chip: 'current', - participatesInGlobalFreshness: true - }, - { - id: expect.any(String), - path: '/home/.factory/skills/orchestration', - chip: 'duplicate', - participatesInGlobalFreshness: true - } - ]) - }) - - it('ranks a read failure over topology and maps every topology', () => { - const chipFor = (overrides: Partial): string | null => - groupSkillFreshness( - [placement('s', { status: 'outdated' }), placement('s', overrides)], - ['s'] - )[0]?.locations.find((location) => location.path.includes('culprit'))?.chip ?? null - const at = (path: string, rest: Partial) => ({ - unresolvedPath: `/culprit/${path}`, - ...rest - }) - expect(chipFor(at('b', { status: 'inaccessible', topology: 'read-only' }))).toBe('inaccessible') - expect(chipFor(at('c', { topology: 'independent-copy' }))).toBe('duplicate') - expect(chipFor(at('d', { topology: 'external-link' }))).toBe('external-link') - expect(chipFor(at('e', { topology: 'broken-link' }))).toBe('broken-link') - expect(chipFor(at('f', { topology: 'read-only' }))).toBe('read-only') - expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo') - expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache') - expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current') - expect(chipFor(at('k', { status: 'outdated', topology: 'provider-alias' }))).toBeNull() - expect(chipFor(at('m', { status: 'inaccessible', topology: 'repo-scope' }))).toBe( - 'inaccessible' - ) - }) - - it('raises no group when every finding is project-owned', () => { - expect( - groupSkillFreshness( - [placement('computer-use', { status: 'outdated', topology: 'repo-scope' })], - [] - ) - ).toEqual([]) - }) - - it('still lists a project copy inside a group another placement earned', () => { - const groups = groupSkillFreshness( - [ - placement('computer-use', { status: 'outdated', topology: 'read-only' }), - placement('computer-use', { - status: 'outdated', - topology: 'repo-scope', - unresolvedPath: '/home/projects/work/.agents/skills/computer-use' - }) - ], - [] - ) - - expect(groups).toHaveLength(1) - expect(groups[0]?.locations.map((location) => location.chip)).toContain('in-a-repo') - }) - - it('marks project and plugin copies as ones the global update never judged', () => { - const groups = groupSkillFreshness( - [ - placement('computer-use', { status: 'outdated', topology: 'read-only' }), - placement('computer-use', { - status: 'outdated', - topology: 'plugin-cache', - unresolvedPath: '/home/.claude/plugins/cache/pack/skills/computer-use' - }), - placement('computer-use', { - status: 'outdated', - topology: 'repo-scope', - unresolvedPath: '/home/projects/work/.agents/skills/computer-use' - }) - ], - [] - ) - - expect( - groups[0]?.locations.map((location) => [ - location.chip, - location.participatesInGlobalFreshness - ]) - ).toEqual([ - ['read-only', true], - ['plugin-cache', false], - ['in-a-repo', false] - ]) - }) -}) diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.ts b/src/renderer/src/components/skills/skill-freshness-grouping.ts deleted file mode 100644 index 56742c3cb6c..00000000000 --- a/src/renderer/src/components/skills/skill-freshness-grouping.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - isOrcaSkillLockSource, - isSkillCopyNeedingAttention, - skillPlacementParticipatesInGlobalFreshness, - type SkillFreshnessInstallation -} from '../../../../shared/skill-freshness' - -export type SkillGroupStatus = 'update-available' | 'cannot-update' - -export type SkillLocationChip = - | 'current' - | 'inaccessible' - | 'duplicate' - | 'external-link' - | 'broken-link' - | 'read-only' - | 'in-a-repo' - | 'plugin-cache' - -export type SkillLocationRow = { - id: string - path: string - chip: SkillLocationChip | null - /** False for a project-owned copy: listed for visibility, but never judged, so it - * cannot be the reason a skill was skipped. */ - participatesInGlobalFreshness: boolean -} - -export type SkillFreshnessGroupModel = { - name: string - status: SkillGroupStatus - /** The skills.sh lock says Orca installed this name, so its update is preselected. */ - orcaOwned: boolean - lockSource: string | null - locations: SkillLocationRow[] -} - -export function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null { - if (installation.status === 'inaccessible') { - return 'inaccessible' - } - switch (installation.topology) { - case 'independent-copy': - return 'duplicate' - case 'external-link': - return 'external-link' - case 'broken-link': - return 'broken-link' - case 'read-only': - return 'read-only' - case 'repo-scope': - return 'in-a-repo' - case 'plugin-cache': - return 'plugin-cache' - case 'canonical-copy': - case 'provider-alias': - // Why: the out-of-date copy the update rewrites is the bare one. - return installation.status === 'current' ? 'current' : null - } -} - -/** - * Groups installations by skill for the review modal and derives each skill's - * update disposition. A skill appears when a copy is out of date, or when a copy is - * wrong in a way the update cannot fix — a link out of tree, one Orca could not read. - * - * That second half is what the badge already reports: it turns amber and offers - * Details, and Details opens this modal. Returning only out-of-date skills left that - * modal saying every skill was up to date over an empty list, contradicting the badge - * that sent the user there. Skills where every copy is current are still omitted, as - * is a plugin's or a project's own copy — neither is the user's to fix through the global - * update. Skills Orca installed sort first, since only they are preselected. - * - * `alwaysIncludeNames` overrides that filter. A successful update makes every - * targeted skill current, which would otherwise drop its row the instant the - * re-scan lands — the dialog passes the running/finished run's names so the same - * rows stay put from "update available" through to the result. - */ -export function groupSkillFreshness( - installations: readonly SkillFreshnessInstallation[], - eligibleUpdateNames: readonly string[], - alwaysIncludeNames: readonly string[] = [] -): SkillFreshnessGroupModel[] { - const eligible = new Set(eligibleUpdateNames) - const pinned = new Set(alwaysIncludeNames) - const byName = new Map() - for (const installation of installations) { - const entries = byName.get(installation.name) ?? [] - entries.push(installation) - byName.set(installation.name, entries) - } - const groups: SkillFreshnessGroupModel[] = [] - for (const [name, entries] of byName) { - // Why: the inclusion test only consults placements the global update can reach, so a - // skill whose only finding is a project's or plugin's own copy raises no row at all — a - // "Skipped" row would assert Orca considered an update it never could. Locations below - // are still listed in full, so such a copy stays visible whenever some other placement - // earns the group. - if ( - !pinned.has(name) && - !entries - .filter(skillPlacementParticipatesInGlobalFreshness) - .some((entry) => entry.status === 'outdated' || isSkillCopyNeedingAttention(entry)) - ) { - continue - } - const locations = entries - .map((entry) => ({ - id: entry.id, - path: entry.unresolvedPath, - chip: locationChip(entry), - participatesInGlobalFreshness: skillPlacementParticipatesInGlobalFreshness(entry) - })) - .sort((left, right) => left.path.localeCompare(right.path, 'en')) - const lockSource = entries[0]?.lockSource ?? null - groups.push({ - name, - status: eligible.has(name) ? 'update-available' : 'cannot-update', - orcaOwned: isOrcaSkillLockSource(lockSource), - lockSource, - locations - }) - } - return groups.sort( - (left, right) => - Number(right.orcaOwned) - Number(left.orcaOwned) || left.name.localeCompare(right.name, 'en') - ) -} diff --git a/src/renderer/src/components/skills/skill-freshness-scan-issues.tsx b/src/renderer/src/components/skills/skill-freshness-scan-issues.tsx deleted file mode 100644 index d8979641a15..00000000000 --- a/src/renderer/src/components/skills/skill-freshness-scan-issues.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { SkillFreshnessScanIssue } from '../../../../shared/skill-freshness' -import { translate } from '@/i18n/i18n' - -function issueDescription(issue: SkillFreshnessScanIssue): string { - switch (issue.reason) { - case 'depth-limit': - return translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanDepthLimit', - 'Orca reached its plugin scan depth limit before checking this folder.' - ) - case 'entry-limit': - return translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanEntryLimit', - 'Orca reached its plugin scan entry limit before checking the rest of this cache.' - ) - case 'candidate-limit': - return translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanCandidateLimit', - 'Orca found more same-named skill folders than it can safely inspect.' - ) - case 'manifest-limit': - return translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanManifestLimit', - 'Orca skipped this plugin manifest because it exceeded a safe limit.' - ) - case 'outside-root': - return translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanOutsideRoot', - 'Orca skipped this plugin path because it points outside the plugin cache.' - ) - case 'io-error': - return issue.errorCode - ? translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanIoErrorWithCode', - 'Orca could not read this plugin path ({{value0}}).', - { value0: issue.errorCode } - ) - : translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanIoError', - 'Orca could not read this plugin path.' - ) - case 'issue-limit': - return translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanIssueLimit', - 'Orca found too many skipped plugin folders to list individually.' - ) - } -} - -export function SkillFreshnessScanIssues({ - issues -}: { - issues: readonly SkillFreshnessScanIssue[] -}): React.JSX.Element { - return ( - <> - {issues.map((issue) => ( -
-

{issue.sourceLabel}

-

{issueDescription(issue)}

- - {issue.path} - -
- ))} - - ) -} 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 deleted file mode 100644 index 86ce4ef08b2..00000000000 --- a/src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { SkillLocationRow } from './skill-freshness-grouping' -import { skippedReason } from './skill-freshness-skipped-reason' - -function row( - chip: SkillLocationRow['chip'], - path = `/home/.agents/skills/${chip}` -): SkillLocationRow { - return { id: `row-${chip}-${path}`, path, chip, participatesInGlobalFreshness: true } -} - -/** A project's own copy: listed, but never judged by the global update. */ -function projectRow(chip: SkillLocationRow['chip'] = 'in-a-repo'): SkillLocationRow { - return { - ...row(chip, '/home/projects/work/.agents/skills/orchestration'), - participatesInGlobalFreshness: false - } -} - -describe('skippedReason', () => { - it('names the stale duplicate the global command cannot reach', () => { - const reason = skippedReason([row('current'), row('duplicate')]) - expect(reason).toContain('separate copy') - expect(reason).toContain('only refreshes the main copy') - }) - - it('leads with the harder blocker when several placements are off', () => { - expect(skippedReason([row('duplicate'), row('read-only')])).toContain('read-only location') - expect(skippedReason([row('duplicate'), row('external-link')])).toContain( - 'pointing outside Orca’s skill folders' - ) - }) - - it('does not let a project copy outrank the placement that blocked the update', () => { - // Why by row and not by chip: the scan-limit sentinel is also project-scoped and - // carries 'inaccessible', which would otherwise claim a read failure Orca never hit. - expect(skippedReason([row('duplicate'), projectRow()])).toContain('separate copy') - expect(skippedReason([row('duplicate'), projectRow('inaccessible')])).toContain('separate copy') - }) - - it('explains a project copy when that is the only location there is', () => { - expect(skippedReason([projectRow()])).toContain('This is a project skill, not a global one') - }) - - it('falls back to the generic sentence when no location is at fault', () => { - expect(skippedReason([row('current')])).toContain('left this skill out of the update') - expect(skippedReason([row(null)])).toContain('left this skill out of the update') - expect(skippedReason([])).toContain('left this skill out of the update') - }) -}) diff --git a/src/renderer/src/components/skills/skill-freshness-skipped-reason.ts b/src/renderer/src/components/skills/skill-freshness-skipped-reason.ts deleted file mode 100644 index d9cbb00a22a..00000000000 --- a/src/renderer/src/components/skills/skill-freshness-skipped-reason.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { SkillLocationChip, SkillLocationRow } from './skill-freshness-grouping' -import { translate } from '@/i18n/i18n' - -// Why: a skill is skipped for one concrete reason; lead with the highest-priority -// blocking placement so the sentence explains the real cause rather than a symptom. -const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [ - 'read-only', - 'inaccessible', - 'in-a-repo', - 'plugin-cache', - 'external-link', - 'broken-link', - // Why: lowest priority — a stale duplicate only explains the skip once no - // harder blocker is present, since the others describe a more specific cause. - 'duplicate' -] - -// Why: a location the global update never judged cannot be why the skill was skipped, so it -// must not outrank the placement that was. The fallback keeps the sentence total. -function blockingChip(locations: readonly SkillLocationRow[]): SkillLocationChip | undefined { - const judged = locations.filter((location) => location.participatesInGlobalFreshness) - const present = new Set((judged.length > 0 ? judged : locations).map((location) => location.chip)) - return SKIPPED_REASON_PRIORITY.find((candidate) => present.has(candidate)) -} - -/** - * The one sentence that explains why an update won't reach a skill. Shared by the - * review dialog and the setup rails so the badge and the dialog can never disagree. - * - * The wording is deictic ("this copy") on purpose: it is only ever rendered beside the - * location rows it describes, which is why the setup rails link into the dialog rather - * than repeating a sentence that would have nothing to point at. - */ -export function skippedReason(locations: readonly SkillLocationRow[]): string { - const chip = blockingChip(locations) - switch (chip) { - case 'read-only': - return translate( - 'auto.components.skills.SkillFreshnessRow.skippedReasonReadOnly', - 'This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.' - ) - case 'inaccessible': - return translate( - 'auto.components.skills.SkillFreshnessRow.skippedReasonInaccessible', - 'Orca couldn’t read this copy, so it left the skill out of the update.' - ) - case 'in-a-repo': - return translate( - 'auto.components.skills.SkillFreshnessRow.skippedReasonInRepo', - 'This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.' - ) - case 'plugin-cache': - return translate( - 'auto.components.skills.SkillFreshnessRow.skippedReasonPluginCache', - 'A plugin manages this skill, so Orca left it out of the update — update the plugin instead.' - ) - case 'external-link': - return translate( - 'auto.components.skills.SkillFreshnessRow.skippedReasonExternalLink', - 'This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.' - ) - case 'broken-link': - return translate( - 'auto.components.skills.SkillFreshnessRow.skippedReasonBrokenLink', - 'This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.' - ) - case 'duplicate': - return translate( - 'auto.components.skills.SkillFreshnessRow.skippedReasonDuplicate', - 'This is a separate copy, so the update won’t reach it — the command only refreshes the main copy. Remove this copy, then reinstall the skill so this location follows the main one.' - ) - case 'current': - case undefined: - return translate( - 'auto.components.skills.SkillFreshnessRow.cantUpdateReason', - 'Orca left this skill out of the update command.' - ) - } -} diff --git a/src/renderer/src/components/skills/skill-freshness-summary-headline.tsx b/src/renderer/src/components/skills/skill-freshness-summary-headline.tsx index b5d8f5cb8ab..d1c79fc265e 100644 --- a/src/renderer/src/components/skills/skill-freshness-summary-headline.tsx +++ b/src/renderer/src/components/skills/skill-freshness-summary-headline.tsx @@ -1,113 +1,11 @@ import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react' -import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import type { BundledSkillPlan } from '../../../../shared/bundled-skill-plan' +import { summarizeBundledSkillPlan } from '../../../../shared/bundled-skill-summary' import { translate } from '@/i18n/i18n' -export type FreshnessSummaryKind = - | 'loading' - | 'empty' - | 'eligible' - | 'current' - | 'attention' - | 'scan-incomplete' - -export function summarizeInventory( - inventory: SkillFreshnessInventory | null, - ownedEligibleCount: number, - hasBlockedGroup: boolean, - hasIncompleteScan: boolean -): FreshnessSummaryKind { - if (!inventory) { - return 'loading' - } - // Why: only Orca's own skills count as updates; a same-named copy from elsewhere is - // offered unselected under the headline, so Orca's skills can still read up to date. - if (ownedEligibleCount > 0) { - return 'eligible' - } - // Why: a named skill the update can't converge outranks a coverage gap — the gap - // says something might be unchecked, the group says something definitely is wrong. - if (hasBlockedGroup) { - return 'attention' - } - // Why: a fault on the user's disk, or a bound that ended the walk early. Skipping a - // single folder is not either one — headlining that would put a permanent warning on - // any ordinary large plugin cache, the unclearable amber this change removes. - if (hasIncompleteScan) { - return 'scan-incomplete' - } - // Why: ordered after the scan checks so a scan that never completed is not reported - // as an empty machine — "none found" would be a claim the scan cannot support. - if (inventory.installations.length === 0) { - return 'empty' - } - return 'current' -} - -// Why: the button stays while any row is updatable, so unticking every row doesn't reflow -// the footer; a disabled "Update 0 skills" would read as a broken count. -export function updateActionLabel(count: number): string { - if (count === 0) { - return translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills') - } - return count === 1 - ? translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.updateActionOne', - 'Update 1 skill' - ) - : translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.updateActionMany', - 'Update {{value0}} skills', - { value0: count } - ) -} - -/** Pre-run headline. Once a run starts, the dialog reports the run instead. */ -export function SummaryHeadline({ - kind, - eligibleCount, - blockedCount, - unselectedForeignCount -}: { - kind: FreshnessSummaryKind - eligibleCount: number - blockedCount: number - unselectedForeignCount: number -}): React.JSX.Element { - const main = ( - - ) - if (kind === 'loading' || kind === 'empty' || unselectedForeignCount === 0) { - return main - } - return ( -
- {main} -

- {unselectedForeignCount === 1 - ? translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.unselectedForeignOne', - '1 skill not installed by Orca is unselected.' - ) - : translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.unselectedForeignMany', - '{{value0}} skills not installed by Orca are unselected.', - { value0: unselectedForeignCount } - )} -

-
- ) -} - -function SummaryHeadlineMain({ - kind, - eligibleCount, - blockedCount -}: { - kind: FreshnessSummaryKind - eligibleCount: number - blockedCount: number -}): React.JSX.Element { - if (kind === 'loading') { +/** Pre-run headline, read from the live plan; once a run starts the dialog reports it. */ +export function SummaryHeadline({ plan }: { plan: BundledSkillPlan | null }): React.JSX.Element { + if (!plan) { return (
@@ -118,7 +16,7 @@ function SummaryHeadlineMain({
) } - if (kind === 'empty') { + if (plan.skills.length === 0) { return (

{translate( @@ -128,47 +26,23 @@ function SummaryHeadlineMain({

) } - if (kind === 'current') { - return ( -
- - {translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.success', - 'All installed Orca skills are up to date.' - )} -
- ) - } - if (kind === 'attention') { - // No follow-up sentence: skipped rows open themselves, so the reason is - // already on screen directly under this headline. + const summary = summarizeBundledSkillPlan(plan) + const updates = summary.skills.filter((skill) => skill.state === 'update').length + if (summary.badge === 'repair') { return (
{translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.attention', - 'Some installed Orca skills were left out of the update.' + 'auto.components.skills.SkillFreshnessUpdateDialog.repair', + 'An earlier update was interrupted. Update to repair it.' )}
) } - if (kind === 'scan-incomplete') { - // No follow-up sentence, matching 'attention': the skipped folders are listed - // directly under this headline, so pointing at a details panel would be stale. + if (updates > 0) { return ( -
- - {translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.scanIncomplete', - 'Orca could not finish checking plugin-managed skills.' - )} -
- ) - } - return ( -

- {eligibleCount === 1 + {updates === 1 ? translate( 'auto.components.skills.SkillFreshnessUpdateDialog.updateOne', '1 update available' @@ -176,23 +50,43 @@ function SummaryHeadlineMain({ : translate( 'auto.components.skills.SkillFreshnessUpdateDialog.updateMany', '{{value0}} updates available', - { value0: eligibleCount } + { value0: updates } )}

- {blockedCount > 0 ? ( -

- {blockedCount === 1 + ) + } + if ( + summary.skills.some((skill) => skill.locations.some((location) => location.state === 'leave')) + ) { + const current = summary.skills.filter((skill) => skill.upToDate).length + return ( +

+ {current > 0 ? : null} + {current === 0 + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.noUpdates', + 'No updates available' + ) + : current === 1 ? translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.blockedOne', - "1 skill can't be updated automatically." + 'auto.components.skills.SkillFreshnessUpdateDialog.currentOne', + '1 skill is up to date' ) : translate( - 'auto.components.skills.SkillFreshnessUpdateDialog.blockedMany', - "{{value0}} skills can't be updated automatically.", - { value0: blockedCount } + 'auto.components.skills.SkillFreshnessUpdateDialog.currentMany', + '{{value0}} skills are up to date', + { value0: current } )} -

- ) : null} +
+ ) + } + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.success', + 'All installed Orca skills are up to date.' + )}
) } diff --git a/src/renderer/src/components/skills/skill-location-chip-copy.ts b/src/renderer/src/components/skills/skill-location-chip-copy.ts deleted file mode 100644 index 97003450a20..00000000000 --- a/src/renderer/src/components/skills/skill-location-chip-copy.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { SkillLocationChip } from './skill-freshness-grouping' -import { translate } from '@/i18n/i18n' - -export function chipLabel(chip: SkillLocationChip): string { - switch (chip) { - case 'current': - return translate('auto.components.skills.SkillFreshnessRow.chipCurrent', 'Current') - case 'inaccessible': - return translate('auto.components.skills.SkillFreshnessRow.chipInaccessible', 'Inaccessible') - case 'duplicate': - return translate('auto.components.skills.SkillFreshnessRow.chipDuplicate', 'Duplicate') - case 'external-link': - return translate('auto.components.skills.SkillFreshnessRow.chipExternalLink', 'External link') - case 'broken-link': - return translate('auto.components.skills.SkillFreshnessRow.chipBrokenLink', 'Broken link') - case 'read-only': - return translate('auto.components.skills.SkillFreshnessRow.chipReadOnly', 'Read only') - case 'in-a-repo': - return translate('auto.components.skills.SkillFreshnessRow.chipInRepo', 'In a repo') - case 'plugin-cache': - return translate('auto.components.skills.SkillFreshnessRow.chipPluginCache', 'Plugin cache') - } -} - -// Why: chips describe only what a location *is*; the effect on the update -// command lives in the per-skill sentence, so the two never say it twice. -export function chipTooltip(chip: SkillLocationChip): string { - switch (chip) { - case 'current': - return translate( - 'auto.components.skills.SkillFreshnessRow.tipCurrent', - 'This copy matches the current official version.' - ) - case 'inaccessible': - return translate( - 'auto.components.skills.SkillFreshnessRow.tipInaccessible', - 'Orca couldn’t read this copy (a permissions or file error).' - ) - case 'duplicate': - return translate( - 'auto.components.skills.SkillFreshnessRow.tipDuplicate', - 'A separate copy of this skill, installed apart from the main one.' - ) - case 'external-link': - return translate( - 'auto.components.skills.SkillFreshnessRow.tipExternalLink', - 'A shortcut pointing outside Orca’s skill folders.' - ) - case 'broken-link': - return translate( - 'auto.components.skills.SkillFreshnessRow.tipBrokenLink', - 'A shortcut to something that no longer exists.' - ) - case 'read-only': - return translate( - 'auto.components.skills.SkillFreshnessRow.tipReadOnly', - 'This copy is in a read-only location.' - ) - case 'in-a-repo': - return translate( - 'auto.components.skills.SkillFreshnessRow.tipInRepo', - 'This copy lives inside a project, not your global skills.' - ) - case 'plugin-cache': - return translate( - 'auto.components.skills.SkillFreshnessRow.tipPluginCache', - 'This copy is managed by a plugin.' - ) - } -} diff --git a/src/renderer/src/components/skills/skill-plan-op-copy.ts b/src/renderer/src/components/skills/skill-plan-op-copy.ts new file mode 100644 index 00000000000..371f6845191 --- /dev/null +++ b/src/renderer/src/components/skills/skill-plan-op-copy.ts @@ -0,0 +1,189 @@ +import { + isBundledSkillLeaveReason, + type BundledSkillOutcome, + type BundledSkillPlan +} from '../../../../shared/bundled-skill-plan' +import type { + BundledSkillLocation, + BundledSkillLocationState +} from '../../../../shared/bundled-skill-locations' +import { + bundledSkillInstallAgents, + bundledSkillOutcomeText, + summarizeBundledSkillPlan, + type BundledSkillPlanState, + type BundledSkillResultSummary +} from '../../../../shared/bundled-skill-summary' +import { SKILL_INSTALL_PROVIDERS } from '../../../../shared/skill-install-providers' +import { translate } from '@/i18n/i18n' + +export function updatedSkillsLabel(summary: BundledSkillResultSummary): string { + const { changedCount, leftAloneCount } = summary + const updated = + changedCount === 1 + ? translate('auto.components.skills.SkillFreshnessUpdateDialog.updatedOne', 'Updated 1 skill') + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updatedMany', + 'Updated {{value0}} skills', + { value0: changedCount } + ) + return leftAloneCount > 0 + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updatedSkipped', + '{{value0}} · {{value1}} skipped', + { value0: updated, value1: leftAloneCount } + ) + : updated +} + +export function leaveReasonText(reason: string | null | undefined): string { + if (!reason || !isBundledSkillLeaveReason(reason)) { + return translate( + 'auto.components.skills.skillPlanOpCopy.leftAsIs', + 'This copy was kept as it is' + ) + } + switch (reason) { + case 'edited': + return translate('auto.components.skills.skillPlanOpCopy.edited', 'Skill edited outside Orca') + case 'other': + return translate( + 'auto.components.skills.skillPlanOpCopy.other', + 'A different skill uses this name' + ) + case 'unwritable': + return translate( + 'auto.components.skills.skillPlanOpCopy.unwritable', + "Orca can't write to this folder" + ) + case 'unreadable': + return translate( + 'auto.components.skills.skillPlanOpCopy.unreadable', + "Orca can't read every file" + ) + case 'busy': + return translate( + 'auto.components.skills.skillPlanOpCopy.busy', + 'Another install is still running' + ) + case 'shared-not-current': + return translate( + 'auto.components.skills.skillPlanOpCopy.sharedNotCurrent', + "The shared copy isn't Orca's" + ) + } +} + +/** "Shared folder", or the agents that read this folder. */ +export function locationPlaceLabel(location: BundledSkillLocation): string { + if (location.place.kind === 'shared') { + return translate('auto.components.skills.skillPlanOpCopy.sharedFolder', 'Shared folder') + } + return location.place.names.join(', ') || location.displayPath +} + +/** The action or reason for one planned location. */ +export function locationStateText(location: BundledSkillLocation): string { + const texts: Record string> = { + update: () => translate('auto.components.skills.skillPlanOpCopy.update', 'Update'), + repair: () => translate('auto.components.skills.skillPlanOpCopy.repair', 'Repair'), + install: () => translate('auto.components.skills.skillPlanOpCopy.write', 'Install'), + link: () => translate('auto.components.skills.skillPlanOpCopy.link', 'Link'), + replace: () => translate('auto.components.skills.skillPlanOpCopy.replace', 'Replace'), + keep: () => translate('auto.components.skills.skillPlanOpCopy.keep', 'No change'), + 'keep-link': () => + translate('auto.components.skills.skillPlanOpCopy.keepLink', 'Link, no change'), + leave: () => + translate( + 'auto.components.skills.skillPlanOpCopy.updateSkipped', + 'Update skipped: {{value0}}', + { + value0: leaveReasonText(location.reason) + } + ), + unknown: () => + translate('auto.components.skills.skillPlanOpCopy.needsAttention', 'Needs attention') + } + return texts[location.state]() +} + +/** The skill row's line before Update runs; null when only its locations need saying. */ +export function skillPlanHeadline(state: BundledSkillPlanState): string | null { + switch (state) { + case 'update': + return translate('auto.components.skills.skillPlanOpCopy.updateAvailable', 'Update available') + case 'repair': + return translate('auto.components.skills.skillPlanOpCopy.repairAvailable', 'Repair available') + case 'install': + return translate( + 'auto.components.skills.skillPlanOpCopy.installAvailable', + 'Install available' + ) + case 'attention': + case 'current': + return null + } +} + +export function skillOutcomeText(outcome: BundledSkillOutcome): string { + const text = bundledSkillOutcomeText(outcome) + switch (text) { + case 'Updated': + if (outcome.ops.some((op) => op.kind === 'replace' && op.status === 'done')) { + return translate( + 'auto.components.skills.skillPlanOpCopy.replacedWithBackup', + 'Replaced · Your copy was saved as a backup' + ) + } + return translate('auto.components.skills.skillPlanOpCopy.updated', 'Updated') + case 'Installed': + return translate('auto.components.skills.skillPlanOpCopy.installed', 'Installed') + case 'Linked': + return translate('auto.components.skills.skillPlanOpCopy.linked', 'Linked') + case 'No change': + return translate('auto.components.skills.skillPlanOpCopy.keep', 'No change') + case 'Needs attention': + return translate('auto.components.skills.skillPlanOpCopy.needsAttention', 'Needs attention') + } +} + +export function agentNames(agents: readonly string[]): string { + return agents + .map((agent) => SKILL_INSTALL_PROVIDERS.find((provider) => provider.id === agent)?.displayName) + .filter((name): name is string => Boolean(name)) + .join(', ') +} + +export function installForAgentsLabel(agents: readonly string[]): string { + const names = agentNames(agents) + if (!names) { + return translate('auto.components.skills.skillPlanOpCopy.install', 'Install') + } + return translate('auto.components.skills.skillPlanOpCopy.installFor', 'Install for {{value0}}', { + value0: names + }) +} + +/** The Update button's label: an update when any skill needs one, else the agents it links. */ +export function updateActionLabel(plan: BundledSkillPlan): string { + const summary = summarizeBundledSkillPlan(plan) + const updates = summary.skills.filter( + (skill) => skill.state === 'update' || skill.state === 'repair' + ).length + if (updates === 0) { + const agents = bundledSkillInstallAgents(plan.skills) + return agents.length > 0 + ? installForAgentsLabel(agents) + : translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills') + } + return updates === 1 + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateActionOne', + 'Update 1 skill' + ) + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateActionMany', + 'Update {{value0}} skills', + { value0: updates } + ) +} diff --git a/src/renderer/src/components/skills/skill-update-run-store.ts b/src/renderer/src/components/skills/skill-update-run-store.ts index 601fda7da3f..877dc1c60f1 100644 --- a/src/renderer/src/components/skills/skill-update-run-store.ts +++ b/src/renderer/src/components/skills/skill-update-run-store.ts @@ -1,18 +1,44 @@ import { useSyncExternalStore } from 'react' -import type { SkillUpdateRun } from '../../../../shared/skill-freshness' +import type { BundledSkillInstallResult } from '../../../../shared/bundled-skill-plan' +import { + summarizeBundledSkillOutcomes, + type BundledSkillResultSummary +} from '../../../../shared/bundled-skill-summary' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { getSkillFreshnessUpdateDialogRequest, subscribeSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' -// Why: the run outlives the dialog — closing the window must not cancel it, and -// the status-bar segment needs the same snapshot. Keeping it outside React means -// neither surface owns the lifecycle. -let run: SkillUpdateRun = { state: 'idle' } +/** The host whose skills a run changed; WSL and paired hosts get their own key later. */ +export const LOCAL_SKILL_UPDATE_TARGET = 'local' + +export type SkillUpdateRun = + | { state: 'idle' } + | { state: 'running'; targetKey: string; names: string[]; startedAt: number } + | { + state: 'done' + targetKey: string + names: string[] + finishedAt: number + result: BundledSkillInstallResult + summary: BundledSkillResultSummary + needsAttention: boolean + } + /** The request itself failed; nothing says what reached the disk. */ + | { state: 'error'; targetKey: string; names: string[]; finishedAt: number; message: string } + +type RunSlot = { + run: SkillUpdateRun + token: number + successTimer: ReturnType | null +} + +const IDLE: SkillUpdateRun = Object.freeze({ state: 'idle' }) +// Why per target: a run on one host must never overwrite, or be overwritten by, another's. +const slots = new Map() const listeners = new Set<() => void>() -let subscribed = false -let successTimer: ReturnType | null = null +let nextToken = 0 /** How long a finished run keeps its green check in the status bar. */ export const SKILL_UPDATE_SUCCESS_LINGER_MS = 4000 @@ -23,107 +49,133 @@ function emit(): void { } } -function clearSuccessTimer(): void { - if (successTimer) { - clearTimeout(successTimer) - successTimer = null +function slotFor(targetKey: string): RunSlot { + let slot = slots.get(targetKey) + if (!slot) { + slot = { run: IDLE, token: 0, successTimer: null } + slots.set(targetKey, slot) + } + return slot +} + +function isSuccess(run: SkillUpdateRun): boolean { + return run.state === 'done' && run.summary.ok && !run.needsAttention +} + +function clearSuccessTimer(slot: RunSlot): void { + if (slot.successTimer) { + clearTimeout(slot.successTimer) + slot.successTimer = null } } /** - * Why: a success needs to be *seen*, then get out of the way. Errors stay until - * the user acts on them. The dialog renders this same run, so retiring it while - * the dialog is open would yank the result rows out from under someone reading - * them — there, closing the dialog is what acknowledges the run. + * Why: a success needs to be *seen*, then get out of the way. Problems stay until the user + * acts on them, and while the dialog is open closing it is what acknowledges the run. */ -function scheduleSuccessLinger(): void { - clearSuccessTimer() - if (run.state !== 'success' || getSkillFreshnessUpdateDialogRequest()) { +function scheduleSuccessLinger(targetKey: string): void { + const slot = slotFor(targetKey) + clearSuccessTimer(slot) + if (!isSuccess(slot.run) || getSkillFreshnessUpdateDialogRequest()) { return } - successTimer = setTimeout(() => { - successTimer = null - void acknowledgeSkillUpdateRun() + slot.successTimer = setTimeout(() => { + slot.successTimer = null + acknowledgeSkillUpdateRun(targetKey) }, SKILL_UPDATE_SUCCESS_LINGER_MS) } // Opening the dialog on a lingering success hands ownership back to it. -subscribeSkillFreshnessUpdateDialog(scheduleSuccessLinger) - -function setRun(next: SkillUpdateRun): void { - const wasRunning = run.state === 'running' - run = next - scheduleSuccessLinger() - // A run that stopped changes what's on disk — including a cancelled one, which - // may have written several skills before the kill landed. Without this a Stop - // leaves the rows and the count describing the pre-run scan, so the Update - // button re-offers skills that already updated. - if (next.state === 'success' || next.state === 'error' || (wasRunning && next.state === 'idle')) { - notifyInstalledAgentSkillsChanged() +subscribeSkillFreshnessUpdateDialog(() => { + for (const targetKey of slots.keys()) { + scheduleSuccessLinger(targetKey) } +}) + +function setRun(targetKey: string, run: SkillUpdateRun): void { + slotFor(targetKey).run = run + scheduleSuccessLinger(targetKey) emit() } -function ensureSubscribed(): void { - if (subscribed) { - return - } - subscribed = true - window.api.skills.onUpdateRun(setRun) - void window.api.skills.getUpdateRun().then((current) => { - // Don't clobber a live push that landed while this promise was in flight. - if (run.state === 'idle') { - setRun(current) - } - }) -} - export function subscribeSkillUpdateRun(listener: () => void): () => void { - ensureSubscribed() listeners.add(listener) return () => listeners.delete(listener) } -export function getSkillUpdateRun(): SkillUpdateRun { - return run +export function getSkillUpdateRun(targetKey: string = LOCAL_SKILL_UPDATE_TARGET): SkillUpdateRun { + return slots.get(targetKey)?.run ?? IDLE } -export function useSkillUpdateRun(): SkillUpdateRun { - return useSyncExternalStore(subscribeSkillUpdateRun, getSkillUpdateRun, getSkillUpdateRun) +export function useSkillUpdateRun(targetKey: string = LOCAL_SKILL_UPDATE_TARGET): SkillUpdateRun { + const read = (): SkillUpdateRun => getSkillUpdateRun(targetKey) + return useSyncExternalStore(subscribeSkillUpdateRun, read, read) } -// Why: every caller fires these from an event handler with `void`. Swallowing -// here rather than at each call site keeps a dropped IPC from surfacing as an -// unhandled rejection; the run state itself is pushed from main either way. -export async function startSkillUpdateRun(names: readonly string[]): Promise { - ensureSubscribed() - try { - await window.api.skills.startUpdateRun([...names]) - } catch (error) { - console.error('Failed to start skill update run', error) +/** Executes the plan the user reviewed, identified by its digest. */ +export async function startSkillUpdateRun(request: { + /** Only the local host has a plan route in this build. */ + targetKey: typeof LOCAL_SKILL_UPDATE_TARGET + names: string[] + planDigest: string + force: string[] +}): Promise { + const { targetKey, names } = request + const slot = slotFor(targetKey) + if (slot.run.state === 'running') { + return } + const token = ++nextToken + slot.token = token + setRun(targetKey, { state: 'running', targetKey, names, startedAt: Date.now() }) + const settled = await window.api.skills + .bundledExecute({ planDigest: request.planDigest, force: request.force }) + .then( + (result): SkillUpdateRun => { + const summary = summarizeBundledSkillOutcomes(result) + return { + state: 'done', + targetKey, + names, + finishedAt: Date.now(), + result, + summary, + needsAttention: summary.skills.some((skill) => skill.text === 'Needs attention') + } + }, + (error: unknown): SkillUpdateRun => ({ + state: 'error', + targetKey, + names, + finishedAt: Date.now(), + message: error instanceof Error ? error.message : String(error) + }) + ) + // Why: a completion for a run that was acknowledged or replaced is stale. + if (slots.get(targetKey)?.token !== token) { + return + } + setRun(targetKey, settled) + // A run of any outcome may have changed the disk, so every skills surface re-reads it. + notifyInstalledAgentSkillsChanged() } -export async function cancelSkillUpdateRun(): Promise { - try { - await window.api.skills.cancelUpdateRun() - } catch (error) { - console.error('Failed to cancel skill update run', error) - } -} - -export async function acknowledgeSkillUpdateRun(): Promise { - try { - await window.api.skills.acknowledgeUpdateRun() - } catch (error) { - console.error('Failed to acknowledge skill update run', error) +/** Clears a settled run so the status-bar segment can retire itself. */ +export function acknowledgeSkillUpdateRun(targetKey: string = LOCAL_SKILL_UPDATE_TARGET): void { + const slot = slots.get(targetKey) + if (!slot || slot.run.state === 'idle' || slot.run.state === 'running') { + return } + clearSuccessTimer(slot) + slot.token = ++nextToken + setRun(targetKey, IDLE) } /** @internal - tests need a clean module between cases. */ export function _resetSkillUpdateRunStore(): void { - run = { state: 'idle' } - subscribed = false - clearSuccessTimer() + for (const slot of slots.values()) { + clearSuccessTimer(slot) + } + slots.clear() listeners.clear() } diff --git a/src/renderer/src/components/skills/use-reviewed-skill-plan.ts b/src/renderer/src/components/skills/use-reviewed-skill-plan.ts new file mode 100644 index 00000000000..7288d077ac0 --- /dev/null +++ b/src/renderer/src/components/skills/use-reviewed-skill-plan.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import type { BundledSkillPlan } from '../../../../shared/bundled-skill-plan' + +type ReviewedPlan = { plan: BundledSkillPlan | null; loading: boolean; error: string | null } + +type ForcedPlanResult = { request: string; plan: BundledSkillPlan | null; error: string | null } + +/** + * The plan the dialog shows and executes. Replace toggles re-plan with `force`, so main still + * decides every op; the renderer never edits one. Without toggles it is the shared plan. + */ +export function useReviewedSkillPlan( + sharedPlan: BundledSkillPlan | null, + force: readonly string[] +): ReviewedPlan { + // Why the shared digest: a re-check of the shared plan must re-plan the forced one too. + const request = + force.length > 0 && sharedPlan ? `${sharedPlan.planDigest}\n${force.join('\n')}` : '' + const [result, setResult] = useState(null) + + useEffect(() => { + if (!request) { + return + } + let cancelled = false + const forced = request.split('\n').slice(1) + window.api.skills.bundledPlan({ force: forced }).then( + (plan) => { + if (!cancelled) { + setResult({ request, plan, error: null }) + } + }, + (error: unknown) => { + if (!cancelled) { + setResult({ + request, + plan: null, + error: error instanceof Error ? error.message : String(error) + }) + } + } + ) + return () => { + cancelled = true + } + }, [request]) + + if (!request) { + return { plan: sharedPlan, loading: false, error: null } + } + // Why: a result for an older toggle set is never shown as this one's. + if (result?.request !== request) { + return { plan: null, loading: true, error: null } + } + return { plan: result.plan, loading: false, error: result.error } +} diff --git a/src/renderer/src/components/skills/use-skill-update-selection.ts b/src/renderer/src/components/skills/use-skill-update-selection.ts deleted file mode 100644 index 6edf5d9262f..00000000000 --- a/src/renderer/src/components/skills/use-skill-update-selection.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useState } from 'react' -import type { SkillFreshnessGroupModel } from './skill-freshness-grouping' - -export type SkillUpdateSelection = { - checkboxFor: (group: SkillFreshnessGroupModel) => { - checked: boolean - onCheckedChange: (checked: boolean) => void - } - reset: () => void - selectedNames: string[] - ownedCount: number - unselectedForeignCount: number -} - -// Why: a choice is consent for the copy the user saw; if a re-scan finds a different -// install source, the choice lapses back to the ownership default. -function choiceKey(group: SkillFreshnessGroupModel): string { - return `${group.name}\0${group.lockSource ?? ''}` -} - -/** - * Which updatable skills the next run rewrites. Orca's own skills are preselected; - * a same-named copy from elsewhere is only replaced once the user ticks it. - */ -export function useSkillUpdateSelection( - groups: readonly SkillFreshnessGroupModel[] -): SkillUpdateSelection { - const [choices, setChoices] = useState>(new Map()) - const isSelected = (group: SkillFreshnessGroupModel): boolean => - choices.get(choiceKey(group)) ?? group.orcaOwned - const updatable = groups.filter((group) => group.status === 'update-available') - return { - checkboxFor: (group) => ({ - checked: isSelected(group), - onCheckedChange: (checked) => - setChoices((previous) => new Map(previous).set(choiceKey(group), checked)) - }), - reset: () => setChoices(new Map()), - selectedNames: updatable.filter(isSelected).map((group) => group.name), - ownedCount: updatable.filter((group) => group.orcaOwned).length, - unselectedForeignCount: updatable.filter((group) => !group.orcaOwned && !isSelected(group)) - .length - } -} diff --git a/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx b/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx index c6060cc1f77..65cde5d0655 100644 --- a/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx @@ -3,11 +3,13 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SkillUpdateRun } from '../../../../shared/skill-freshness' +import type { BundledSkillInstallResult } from '../../../../shared/bundled-skill-plan' import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment' import { _resetSkillUpdateRunStore, - SKILL_UPDATE_SUCCESS_LINGER_MS + LOCAL_SKILL_UPDATE_TARGET, + SKILL_UPDATE_SUCCESS_LINGER_MS, + startSkillUpdateRun } from '../skills/skill-update-run-store' import { consumeSkillFreshnessUpdateDialogRequest, @@ -24,17 +26,15 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipContent: ({ children }: { children?: ReactNode }) =>
{children}
})) +let settle: ((result: BundledSkillInstallResult) => void) | null = null const skillsApi = { - startUpdateRun: vi.fn(async () => ({ started: true as const })), - cancelUpdateRun: vi.fn(async () => {}), - acknowledgeUpdateRun: vi.fn(async () => {}), - getUpdateRun: vi.fn(async (): Promise => ({ state: 'idle' })), - onUpdateRun: vi.fn((callback: (run: SkillUpdateRun) => void) => { - pushRun = callback - return () => {} - }) + bundledExecute: vi.fn( + () => + new Promise((resolve) => { + settle = resolve + }) + ) } -let pushRun: ((run: SkillUpdateRun) => void) | null = null let root: Root | null = null let container: HTMLDivElement | null = null @@ -48,19 +48,47 @@ async function render(): Promise { }) } -async function emitRun(run: SkillUpdateRun): Promise { +let running: Promise | null = null + +async function start(names: string[]): Promise { await act(async () => { - pushRun?.(run) + running = startSkillUpdateRun({ + targetKey: LOCAL_SKILL_UPDATE_TARGET, + names, + planDigest: 'a'.repeat(64), + force: [] + }) }) } +async function finish(result: BundledSkillInstallResult): Promise { + await act(async () => { + settle?.(result) + await running + }) +} + +function ran(status: 'done' | 'failed', name = 'orca-cli'): BundledSkillInstallResult { + return { + scope: 'global', + skills: [ + { + name, + status: 'ran', + ops: [{ path: `/home/u/.agents/skills/${name}`, kind: 'update', status }] + } + ] + } +} + describe('SkillUpdateStatusSegment', () => { beforeEach(() => { vi.useFakeTimers() _resetSkillUpdateRunStore() consumeSkillFreshnessUpdateDialogRequest() - pushRun = null - skillsApi.acknowledgeUpdateRun.mockClear() + settle = null + running = null + skillsApi.bundledExecute.mockClear() ;(window as unknown as { api: { skills: typeof skillsApi } }).api = { skills: skillsApi } }) @@ -81,12 +109,7 @@ describe('SkillUpdateStatusSegment', () => { it('shows a spinner and skill count while the run is in flight', async () => { await render() - await emitRun({ - state: 'running', - names: ['orca-cli', 'orchestration'], - startedAt: 1, - output: '' - }) + await start(['orca-cli', 'orchestration']) expect(container?.textContent).toContain('Updating skills') expect(container?.querySelector('.animate-spin')).not.toBeNull() @@ -95,42 +118,109 @@ describe('SkillUpdateStatusSegment', () => { it('shows a green check on success, then retires itself', async () => { await render() - await emitRun({ state: 'success', names: ['orca-cli'], finishedAt: 2, output: '' }) + await start(['orca-cli']) + await finish(ran('done')) expect(container?.textContent).toContain('Skills updated') expect(container?.querySelector('.text-emerald-500')).not.toBeNull() - expect(skillsApi.acknowledgeUpdateRun).not.toHaveBeenCalled() await act(async () => { vi.advanceTimersByTime(SKILL_UPDATE_SUCCESS_LINGER_MS) }) - // The linger elapsing asks main to clear the run; main echoes idle back. - expect(skillsApi.acknowledgeUpdateRun).toHaveBeenCalledTimes(1) - await emitRun({ state: 'idle' }) expect(container?.querySelector('button')).toBeNull() }) - it('keeps a failure visible instead of auto-clearing it', async () => { + it.each([1, 2, 3])('counts completed updates with %i skipped skills', async (skippedCount) => { await render() - await emitRun({ - state: 'error', - names: ['orca-cli'], - failedNames: ['orca-cli'], - finishedAt: 3, - output: '', - message: 'exited with code 1' + const names = ['orca-cli', 'orchestration', 'orca-linear', 'computer-use'].slice( + 0, + skippedCount + 1 + ) + await start(names) + expect(container?.querySelector('[data-tooltip]')?.textContent).toBe( + `Updating ${names.length} skills…` + ) + const skills = [...ran('done', names[1]).skills] + skills.unshift({ + name: names[0], + status: 'ran', + ops: [ + { + path: `/home/u/.claude/skills/${names[0]}`, + kind: 'leave', + status: 'done', + reason: 'edited' + } + ] }) + for (const name of names.slice(2)) { + skills.push({ + ...skills[0], + name, + ops: [{ ...skills[0].ops[0], path: `/home/u/.claude/skills/${name}` }] + }) + } + await finish({ scope: 'global', skills }) + + expect(container?.textContent).toContain('Skills updated') + expect(container?.querySelector('[data-tooltip]')?.textContent).toBe( + `Updated 1 skill · ${skippedCount} skipped` + ) + }) + + it('counts completed updates when every requested skill changed', async () => { + await render() + await start(['orca-cli', 'orchestration']) + await finish({ + scope: 'global', + skills: [...ran('done').skills, ...ran('done', 'orchestration').skills] + }) + + expect(container?.querySelector('[data-tooltip]')?.textContent).toBe('Updated 2 skills') + }) + + it('keeps a run with problems visible instead of auto-clearing it', async () => { + await render() + await start(['orca-cli']) + await finish(ran('failed')) await act(async () => { vi.advanceTimersByTime(SKILL_UPDATE_SUCCESS_LINGER_MS * 3) }) - expect(container?.textContent).toContain('Update failed') - expect(skillsApi.acknowledgeUpdateRun).not.toHaveBeenCalled() + expect(container?.textContent).toContain('Skills need attention') + expect(container?.querySelector('button')?.getAttribute('aria-label')).toBe( + 'Some skill updates were skipped. Click to open details.' + ) + expect(container?.textContent).toContain( + 'Some skill updates were skipped — click to see details' + ) }) + it.each([false, true])( + 'keeps recovery needing attention visible (mixed run: %s)', + async (mixed) => { + await render() + await start(mixed ? ['orca-cli', 'orchestration'] : ['orca-cli']) + const skills = [...ran('done').skills] + skills[0].ops[0].kind = 'restore' + if (mixed) { + skills.push(ran('done', 'orchestration').skills[0]) + } + await finish({ scope: 'global', skills }) + + expect(container?.textContent).toContain('Needs attention') + expect(container?.textContent).not.toContain('Skills updated') + expect(container?.querySelector('.text-emerald-500')).toBeNull() + await act(async () => { + vi.advanceTimersByTime(SKILL_UPDATE_SUCCESS_LINGER_MS * 3) + }) + expect(container?.querySelector('button')).not.toBeNull() + } + ) + it('reopens the update dialog when clicked', async () => { await render() - await emitRun({ state: 'running', names: ['orca-cli'], startedAt: 1, output: '' }) + await start(['orca-cli']) await act(async () => { container?.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) diff --git a/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.tsx b/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.tsx index 08e06083525..065aadc02de 100644 --- a/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SkillUpdateStatusSegment.tsx @@ -3,6 +3,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { translate } from '@/i18n/i18n' import { requestSkillFreshnessUpdateDialog } from '../skills/skill-freshness-update-dialog' import { useSkillUpdateRun } from '../skills/skill-update-run-store' +import { updatedSkillsLabel } from '../skills/skill-plan-op-copy' // Why: always rendered (not gated by `statusBarItems`). The dialog can be closed // while the run continues, so this segment is the only surface left reporting it. @@ -18,25 +19,6 @@ export function SkillUpdateStatusSegment({ } const segment = (() => { - if (run.state === 'running' && run.stopping) { - // The dialog can be closed while a Stop is still killing the tree; this is - // then the only surface reporting it, and "Updating skills" would be wrong. - return { - icon: , - label: translate( - 'auto.components.status.bar.SkillUpdateStatusSegment.stoppingLabel', - 'Stopping update' - ), - tooltip: translate( - 'auto.components.status.bar.SkillUpdateStatusSegment.stoppingTooltip', - 'Stopping the skill update…' - ), - ariaLabel: translate( - 'auto.components.status.bar.SkillUpdateStatusSegment.stoppingAria', - 'Stopping the skill update. Click to open details.' - ) - } - } if (run.state === 'running') { return { icon: , @@ -62,25 +44,14 @@ export function SkillUpdateStatusSegment({ ) } } - if (run.state === 'success') { + if (run.state === 'done' && run.summary.ok && !run.needsAttention) { return { icon: , label: translate( 'auto.components.status.bar.SkillUpdateStatusSegment.successLabel', 'Skills updated' ), - tooltip: - run.names.length === 1 - ? translate( - 'auto.components.status.bar.SkillUpdateStatusSegment.successOne', - 'Updated {{value0}}', - { value0: run.names[0] } - ) - : translate( - 'auto.components.status.bar.SkillUpdateStatusSegment.successMany', - 'Updated {{value0}} skills', - { value0: run.names.length } - ), + tooltip: updatedSkillsLabel(run.summary), ariaLabel: translate( 'auto.components.status.bar.SkillUpdateStatusSegment.successAria', 'Skills updated. Click to open details.' @@ -89,17 +60,20 @@ export function SkillUpdateStatusSegment({ } return { icon: , - label: translate( - 'auto.components.status.bar.SkillUpdateStatusSegment.errorLabel', - 'Update failed' - ), + label: + run.state === 'done' && run.summary.ok && run.needsAttention + ? translate('auto.components.skills.skillPlanOpCopy.needsAttention', 'Needs attention') + : translate( + 'auto.components.status.bar.SkillUpdateStatusSegment.attentionLabel', + 'Skills need attention' + ), tooltip: translate( - 'auto.components.status.bar.SkillUpdateStatusSegment.errorTooltip', - 'Skill update failed — click to see details' + 'auto.components.status.bar.SkillUpdateStatusSegment.attentionTooltip', + 'Some skill updates were skipped — click to see details' ), ariaLabel: translate( - 'auto.components.status.bar.SkillUpdateStatusSegment.errorAria', - 'Skill update failed. Click to open details.' + 'auto.components.status.bar.SkillUpdateStatusSegment.attentionAria', + 'Some skill updates were skipped. Click to open details.' ) } })() diff --git a/src/renderer/src/hooks/useSkillFreshness.test.tsx b/src/renderer/src/hooks/useSkillFreshness.test.tsx index e5fc74a1ff9..b9c3f9fa317 100644 --- a/src/renderer/src/hooks/useSkillFreshness.test.tsx +++ b/src/renderer/src/hooks/useSkillFreshness.test.tsx @@ -3,7 +3,8 @@ import { act, StrictMode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import type { BundledSkillPlan } from '../../../shared/bundled-skill-plan' +import { planOf, sharedEntry } from '../components/skills/bundled-skill-plan-fixtures.test-support' import { _skillFreshnessCacheForTests, type SkillFreshnessState, @@ -24,20 +25,28 @@ function deferred(): { return { promise, resolve, reject } } -function inventory(scannedAt: number, eligibleUpdateNames: string[] = []): SkillFreshnessInventory { - return { schemaVersion: 1, installations: [], eligibleUpdateNames, scanIssues: [], scannedAt } +function digest(id: number): string { + return String(id).padStart(64, '0') +} + +/** A plan identified by `id`; `updates` names skills with update work. */ +function plan(id: number, updates: string[] = []): BundledSkillPlan { + return planOf( + updates.map((name) => sharedEntry(name, 'update')), + digest(id) + ) } let root: Root | null = null let container: HTMLDivElement | null = null let state: SkillFreshnessState | null = null const states = new Map() -const renderedInventories: (SkillFreshnessInventory | null)[] = [] +const renderedPlans: (BundledSkillPlan | null)[] = [] function Probe({ id = 'default', enabled = true }: { id?: string; enabled?: boolean }): null { state = useSkillFreshness(enabled) states.set(id, state) - renderedInventories.push(state.inventory) + renderedPlans.push(state.plan) return null } @@ -46,7 +55,7 @@ describe('useSkillFreshness', () => { _skillFreshnessCacheForTests.reset() state = null states.clear() - renderedInventories.length = 0 + renderedPlans.length = 0 container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -63,37 +72,39 @@ describe('useSkillFreshness', () => { container = null }) - it('runs a follow-up scan when invalidated during an in-flight request', async () => { - const first = deferred() - const second = deferred() - const freshnessInventory = vi + it('runs a follow-up plan when invalidated during an in-flight request', async () => { + const first = deferred() + const second = deferred() + const bundledPlan = vi .fn() .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise) - window.api = { skills: { freshnessInventory } } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render()) - expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(bundledPlan).toHaveBeenCalledTimes(1) await act(async () => window.dispatchEvent(new Event('focus'))) - await act(async () => first.resolve(inventory(1))) - expect(freshnessInventory).toHaveBeenCalledTimes(2) + await act(async () => first.resolve(plan(1))) + expect(bundledPlan).toHaveBeenCalledTimes(2) - await act(async () => second.resolve(inventory(2))) - expect(state?.inventory?.scannedAt).toBe(2) + await act(async () => second.resolve(plan(2))) + expect(state?.plan?.planDigest).toBe(digest(2)) }) it('does not scan, subscribe, or refresh while local freshness is disabled', async () => { - const freshnessInventory = vi.fn().mockResolvedValue(inventory(1)) + const bundledPlan = vi.fn().mockResolvedValue(plan(1)) const addEventListener = vi.spyOn(window, 'addEventListener') - window.api = { skills: { freshnessInventory } } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render()) await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) await act(async () => state?.refresh()) - expect(freshnessInventory).not.toHaveBeenCalled() - expect(state).toMatchObject({ inventory: null, loading: false, error: null }) + expect(bundledPlan).not.toHaveBeenCalled() + expect(state).toMatchObject({ plan: null, loading: false, error: null }) expect( addEventListener.mock.calls.filter( ([name]) => name === 'focus' || name === 'orca:installed-agent-skills-changed' @@ -102,12 +113,13 @@ describe('useSkillFreshness', () => { }) it('forces one fresh scan before restoring authority after re-enable', async () => { - const second = deferred() - const freshnessInventory = vi + const second = deferred() + const bundledPlan = vi .fn() - .mockResolvedValueOnce(inventory(1, ['orca-cli'])) + .mockResolvedValueOnce(plan(1, ['orca-cli'])) .mockReturnValueOnce(second.promise) - window.api = { skills: { freshnessInventory } } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never const renderProbes = (enabled: boolean): void => { root?.render( @@ -118,84 +130,82 @@ describe('useSkillFreshness', () => { } await act(async () => renderProbes(true)) - expect(freshnessInventory).toHaveBeenCalledTimes(1) - expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli']) + expect(bundledPlan).toHaveBeenCalledTimes(1) + expect(state?.plan?.skills.map((skill) => skill.name)).toEqual(['orca-cli']) await act(async () => renderProbes(true)) - expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(bundledPlan).toHaveBeenCalledTimes(1) await act(async () => renderProbes(false)) await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) - expect(freshnessInventory).toHaveBeenCalledTimes(1) - expect(state).toMatchObject({ inventory: null, loading: false }) + expect(bundledPlan).toHaveBeenCalledTimes(1) + expect(state).toMatchObject({ plan: null, loading: false }) - const reenableRenderStart = renderedInventories.length + const reenableRenderStart = renderedPlans.length await act(async () => renderProbes(true)) - expect(freshnessInventory).toHaveBeenCalledTimes(2) - expect(state).toMatchObject({ inventory: null, loading: true }) - expect(renderedInventories.slice(reenableRenderStart).every((value) => value === null)).toBe( - true - ) + expect(bundledPlan).toHaveBeenCalledTimes(2) + expect(state).toMatchObject({ plan: null, loading: true }) + expect(renderedPlans.slice(reenableRenderStart).every((value) => value === null)).toBe(true) - await act(async () => second.resolve(inventory(2))) - expect(state?.inventory?.scannedAt).toBe(2) - expect(state?.inventory?.eligibleUpdateNames).toEqual([]) + await act(async () => second.resolve(plan(2))) + expect(state?.plan?.planDigest).toBe(digest(2)) + expect(state?.plan?.skills.map((skill) => skill.name)).toEqual([]) }) it('skips focus rescans inside the cooldown but honors install-change events', async () => { - const first = deferred() - const second = deferred() - const freshnessInventory = vi + const first = deferred() + const second = deferred() + const bundledPlan = vi .fn() .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise) - window.api = { skills: { freshnessInventory } } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render()) - await act(async () => first.resolve(inventory(1))) - expect(freshnessInventory).toHaveBeenCalledTimes(1) + await act(async () => first.resolve(plan(1))) + expect(bundledPlan).toHaveBeenCalledTimes(1) await act(async () => window.dispatchEvent(new Event('focus'))) - expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(bundledPlan).toHaveBeenCalledTimes(1) await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) - await act(async () => second.resolve(inventory(2))) - expect(freshnessInventory).toHaveBeenCalledTimes(2) - expect(state?.inventory?.scannedAt).toBe(2) + await act(async () => second.resolve(plan(2))) + expect(bundledPlan).toHaveBeenCalledTimes(2) + expect(state?.plan?.planDigest).toBe(digest(2)) }) it('retracts stale update authority during the cooldown and runs one trailing focus scan', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) - const second = deferred() - const freshnessInventory = vi + const second = deferred() + const bundledPlan = vi .fn() - .mockResolvedValueOnce(inventory(1, ['orca-cli'])) + .mockResolvedValueOnce(plan(1, ['orca-cli'])) .mockReturnValueOnce(second.promise) - window.api = { skills: { freshnessInventory } } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render()) - expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli']) + expect(state?.plan?.skills.map((skill) => skill.name)).toEqual(['orca-cli']) await act(async () => window.dispatchEvent(new Event('focus'))) - expect(freshnessInventory).toHaveBeenCalledTimes(1) - expect(state?.inventory).toBeNull() + expect(bundledPlan).toHaveBeenCalledTimes(1) + expect(state?.plan).toBeNull() expect(state?.loading).toBe(true) await act(async () => vi.advanceTimersByTimeAsync(15_000)) - expect(freshnessInventory).toHaveBeenCalledTimes(2) - await act(async () => second.resolve(inventory(2))) - expect(state?.inventory?.scannedAt).toBe(2) + expect(bundledPlan).toHaveBeenCalledTimes(2) + await act(async () => second.resolve(plan(2))) + expect(state?.plan?.planDigest).toBe(digest(2)) }) it('coalesces multiple consumers into one rescan per invalidation event', async () => { - const first = deferred() - const second = deferred() - const freshnessInventory = vi - .fn() - .mockReturnValueOnce(first.promise) - .mockReturnValue(second.promise) - window.api = { skills: { freshnessInventory } } as never + const first = deferred() + const second = deferred() + const bundledPlan = vi.fn().mockReturnValueOnce(first.promise).mockReturnValue(second.promise) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render( @@ -205,21 +215,19 @@ describe('useSkillFreshness', () => { ) ) - await act(async () => first.resolve(inventory(1))) - expect(freshnessInventory).toHaveBeenCalledTimes(1) + await act(async () => first.resolve(plan(1))) + expect(bundledPlan).toHaveBeenCalledTimes(1) await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) - await act(async () => second.resolve(inventory(2))) - expect(freshnessInventory).toHaveBeenCalledTimes(2) + await act(async () => second.resolve(plan(2))) + expect(bundledPlan).toHaveBeenCalledTimes(2) }) it('publishes a manual refresh to every consumer', async () => { - const second = deferred() - const freshnessInventory = vi - .fn() - .mockResolvedValueOnce(inventory(1)) - .mockReturnValueOnce(second.promise) - window.api = { skills: { freshnessInventory } } as never + const second = deferred() + const bundledPlan = vi.fn().mockResolvedValueOnce(plan(1)).mockReturnValueOnce(second.promise) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render( @@ -229,41 +237,39 @@ describe('useSkillFreshness', () => { ) ) - expect(states.get('one')?.inventory?.scannedAt).toBe(1) - expect(states.get('two')?.inventory?.scannedAt).toBe(1) + expect(states.get('one')?.plan?.planDigest).toBe(digest(1)) + expect(states.get('two')?.plan?.planDigest).toBe(digest(1)) let refresh: Promise | undefined await act(async () => { refresh = states.get('one')?.refresh() await Promise.resolve() }) - expect(states.get('one')?.inventory).toBeNull() - expect(states.get('two')?.inventory).toBeNull() + expect(states.get('one')?.plan).toBeNull() + expect(states.get('two')?.plan).toBeNull() - await act(async () => second.resolve(inventory(2))) + await act(async () => second.resolve(plan(2))) await refresh - expect(states.get('one')?.inventory?.scannedAt).toBe(2) - expect(states.get('two')?.inventory?.scannedAt).toBe(2) - expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(states.get('one')?.plan?.planDigest).toBe(digest(2)) + expect(states.get('two')?.plan?.planDigest).toBe(digest(2)) + expect(bundledPlan).toHaveBeenCalledTimes(2) }) it('fails closed when an invalidation scan rejects', async () => { - const second = deferred() - const freshnessInventory = vi - .fn() - .mockResolvedValueOnce(inventory(1)) - .mockReturnValueOnce(second.promise) - window.api = { skills: { freshnessInventory } } as never + const second = deferred() + const bundledPlan = vi.fn().mockResolvedValueOnce(plan(1)).mockReturnValueOnce(second.promise) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. + window.api = { skills: { bundledPlan } } as never await act(async () => root?.render()) - expect(state?.inventory?.scannedAt).toBe(1) + expect(state?.plan?.planDigest).toBe(digest(1)) await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) - expect(state?.inventory).toBeNull() + expect(state?.plan).toBeNull() expect(state?.loading).toBe(true) await act(async () => second.reject(new Error('scan failed'))) - expect(state?.inventory).toBeNull() + expect(state?.plan).toBeNull() expect(state?.loading).toBe(false) expect(state?.error).toBe('scan failed') }) @@ -271,8 +277,9 @@ describe('useSkillFreshness', () => { it('installs one event-listener pair for multiple consumers and cleans it up', async () => { const addEventListener = vi.spyOn(window, 'addEventListener') const removeEventListener = vi.spyOn(window, 'removeEventListener') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double implements only the preload methods this code path calls. window.api = { - skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) } + skills: { bundledPlan: vi.fn().mockResolvedValue(plan(1)) } } as never await act(async () => diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts index 568ad67b3fb..9f4d50bf723 100644 --- a/src/renderer/src/hooks/useSkillFreshness.ts +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -1,12 +1,13 @@ import { useEffect, useRef, useSyncExternalStore } from 'react' -import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import type { BundledSkillPlan } from '../../../shared/bundled-skill-plan' +import { summarizeBundledSkillPlan } from '../../../shared/bundled-skill-summary' import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' -// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes -// every installed package; a just-completed scan stays authoritative briefly. +// Why: window focus fires on every alt-tab, and each plan re-reads and re-hashes every +// target; a just-completed plan stays authoritative briefly. const FOCUS_RESCAN_COOLDOWN_MS = 15_000 -let cachedInventory: SkillFreshnessInventory | null = null -let pendingInventory: Promise | null = null +let cachedPlan: BundledSkillPlan | null = null +let pendingPlan: Promise | null = null let invalidationRevision = 0 let completedRevision = -1 let lastCompletedScanAt = 0 @@ -14,23 +15,23 @@ let refreshSequence = 0 let scheduledFocusRescan: number | null = null type SkillFreshnessSnapshot = { - inventory: SkillFreshnessInventory | null + plan: BundledSkillPlan | null loading: boolean error: string | null } let snapshot: SkillFreshnessSnapshot = { - inventory: null, + plan: null, loading: false, error: null } const DISABLED_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({ - inventory: null, + plan: null, loading: false, error: null }) const REENABLING_SNAPSHOT: SkillFreshnessSnapshot = Object.freeze({ - inventory: null, + plan: null, loading: true, error: null }) @@ -39,7 +40,7 @@ let pendingReenableRefresh: Promise | null = null function publishSnapshot(next: SkillFreshnessSnapshot): void { if ( - snapshot.inventory === next.inventory && + snapshot.plan === next.plan && snapshot.loading === next.loading && snapshot.error === next.error ) { @@ -51,57 +52,56 @@ function publishSnapshot(next: SkillFreshnessSnapshot): void { } } -async function loadInventory(force: boolean): Promise { +async function loadPlan(force: boolean): Promise { if (force) { invalidationRevision += 1 } const targetRevision = invalidationRevision for (;;) { - if (cachedInventory && completedRevision >= targetRevision) { - return cachedInventory + if (cachedPlan && completedRevision >= targetRevision) { + return cachedPlan } - if (!pendingInventory) { + if (!pendingPlan) { const requestRevision = invalidationRevision const request = window.api.skills - .freshnessInventory() - .then((inventory) => { - cachedInventory = inventory + .bundledPlan() + .then((plan) => { + cachedPlan = plan completedRevision = Math.max(completedRevision, requestRevision) lastCompletedScanAt = Date.now() - return inventory + return plan }) .finally(() => { - if (pendingInventory === request) { - pendingInventory = null + if (pendingPlan === request) { + pendingPlan = null } }) - pendingInventory = request + pendingPlan = request } - await pendingInventory + await pendingPlan } } -/** Rescan freshness without going through the hook, for surfaces whose explicit - * refresh affordance must move the shared verdict (the installed-skills refreshed - * event reuses a completed scan and deliberately does not reach this store). */ +/** Re-plan without going through the hook, for surfaces whose explicit refresh affordance + * must move the shared verdict. */ export async function refreshSkillFreshness(force = true): Promise { if (scheduledFocusRescan !== null) { window.clearTimeout(scheduledFocusRescan) scheduledFocusRescan = null } const sequence = ++refreshSequence - // Why: eligibility is write authority for the draft command. Once invalidated, - // stale bytes must stop authorizing UI even if the replacement scan fails. - publishSnapshot({ inventory: null, loading: true, error: null }) + // Why: the plan's digest is what Update executes. Once invalidated, a stale plan must + // stop authorizing UI even if the replacement plan fails. + publishSnapshot({ plan: null, loading: true, error: null }) try { - const inventory = await loadInventory(force) + const plan = await loadPlan(force) if (sequence === refreshSequence) { - publishSnapshot({ inventory, loading: false, error: null }) + publishSnapshot({ plan, loading: false, error: null }) } } catch (cause) { if (sequence === refreshSequence) { publishSnapshot({ - inventory: null, + plan: null, loading: false, error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.' }) @@ -115,12 +115,16 @@ function onWindowFocus(): void { void refreshSkillFreshness(true) return } - if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) { + if ( + !snapshot.plan || + !summarizeBundledSkillPlan(snapshot.plan).hasWork || + scheduledFocusRescan !== null + ) { return } // Why: a focus event can follow an external edit. Retract stale update // authority immediately, but keep rapid alt-tabs to one trailing disk scan. - publishSnapshot({ inventory: null, loading: true, error: null }) + publishSnapshot({ plan: null, loading: true, error: null }) scheduledFocusRescan = window.setTimeout( () => { scheduledFocusRescan = null @@ -138,7 +142,7 @@ function subscribe(subscriber: () => void): () => void { subscribers.add(subscriber) if (subscribers.size === 1) { // Why: every consumer reads one external snapshot, so focus/install events - // install one listener and trigger one shared IPC scan regardless of UI count. + // install one listener and trigger one shared plan regardless of UI count. window.addEventListener('focus', onWindowFocus) window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) } @@ -167,8 +171,8 @@ function getDisabledSnapshot(): SkillFreshnessSnapshot { return DISABLED_SNAPSHOT } -function ensureInventoryLoaded(): void { - if (!snapshot.inventory && !snapshot.loading) { +function ensurePlanLoaded(): void { + if (!snapshot.plan && !snapshot.loading) { void refreshSkillFreshness(false) } } @@ -205,7 +209,7 @@ export function useSkillFreshness(enabled = true): SkillFreshnessState { void refreshSkillFreshnessAfterReenable() return } - ensureInventoryLoaded() + ensurePlanLoaded() }, [enabled]) return { @@ -216,8 +220,8 @@ export function useSkillFreshness(enabled = true): SkillFreshnessState { export const _skillFreshnessCacheForTests = { reset(): void { - cachedInventory = null - pendingInventory = null + cachedPlan = null + pendingPlan = null invalidationRevision = 0 completedRevision = -1 lastCompletedScanAt = 0 @@ -227,6 +231,6 @@ export const _skillFreshnessCacheForTests = { window.clearTimeout(scheduledFocusRescan) scheduledFocusRescan = null } - snapshot = { inventory: null, loading: false, error: null } + snapshot = { plan: null, loading: false, error: null } } } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 9d80b9c1dfc..75f112ba3a1 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2520,6 +2520,10 @@ "c9382662bb": "Restart daemon", "e7ccce7e87": "of system RAM" }, + "SkillUpdateStatusSegment": { + "successMany": "Updated {{value0}} skills", + "successOne": "Updated {{value0}}" + }, "SshStatusSegment": { "3d0128b105": "connected", "63a2b965f6": "pulling", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index dbe7a480ec9..d50482e5c79 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4229,12 +4229,9 @@ "successOne": "Updated {{value0}}", "successMany": "Updated {{value0}} skills", "successAria": "Skills updated. Click to open details.", - "errorLabel": "Update failed", - "errorTooltip": "Skill update failed — click to see details", - "errorAria": "Skill update failed. Click to open details.", - "stoppingLabel": "Stopping update", - "stoppingTooltip": "Stopping the skill update…", - "stoppingAria": "Stopping the skill update. Click to open details." + "attentionAria": "Some skill updates were skipped. Click to open details.", + "attentionLabel": "Skills need attention", + "attentionTooltip": "Some skill updates were skipped — click to see details" }, "UpdateStatusSegment": { "5cd13105a3": "Update failed. Click to expand.", @@ -4980,87 +4977,38 @@ "updateOne": "Update skill", "updateMany": "Update skills" }, - "SkillFreshnessRow": { - "statusUpdateAvailable": "Update available", - "statusCantUpdate": "Skipped", - "cantUpdateReason": "Orca left this skill out of the update command.", - "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", - "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", - "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", - "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", - "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", - "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.", - "chipCurrent": "Current", - "chipInaccessible": "Inaccessible", - "chipDuplicate": "Duplicate", - "chipExternalLink": "External link", - "chipBrokenLink": "Broken link", - "chipReadOnly": "Read only", - "chipInRepo": "In a repo", - "chipPluginCache": "Plugin cache", - "tipCurrent": "This copy matches the current official version.", - "tipInaccessible": "Orca couldn’t read this copy (a permissions or file error).", - "tipDuplicate": "A separate copy of this skill, installed apart from the main one.", - "tipExternalLink": "A shortcut pointing outside Orca’s skill folders.", - "tipBrokenLink": "A shortcut to something that no longer exists.", - "tipReadOnly": "This copy is in a read-only location.", - "tipInRepo": "This copy lives inside a project, not your global skills.", - "tipPluginCache": "This copy is managed by a plugin.", - "skippedReasonDuplicate": "This is a separate copy, so the update won’t reach it — the command only refreshes the main copy. Remove this copy, then reinstall the skill so this location follows the main one." - }, + "SkillFreshnessRow": {}, "SkillFreshnessUpdateDialog": { "title": "Update skills", "checking": "Checking installed Orca skills…", "none": "No installed Orca skills found.", "updateOne": "1 update available", "updateMany": "{{value0}} updates available", - "blockedOne": "1 skill can't be updated automatically.", - "blockedMany": "{{value0}} skills can't be updated automatically.", "success": "All installed Orca skills are up to date.", - "attention": "Some installed Orca skills were left out of the update.", - "runningOne": "Updating 1 skill…", - "runningMany": "Updating {{value0}} skills…", - "runningDescription": "You can close this window — it keeps running in the background.", "progressAria": "Updating skills", - "updatedOne": "Updated 1 skill", - "updatedMany": "Updated {{value0}} skills", - "updatedPartial": "Updated {{value0}} of {{value1}} skills", "errorTitle": "The update didn't finish", - "retry": "Retry", - "copyCommand": "Copy command", - "copied": "Copied", - "showLog": "Show log", "updating": "Updating…", "updateActionOne": "Update 1 skill", "updateActionMany": "Update {{value0}} skills", "checkNow": "Re-check", "done": "Done", "close": "Close", - "stop": "Stop", - "stopping": "Stopping…", - "stoppingHeadline": "Stopping the update…", - "scanIncomplete": "Orca could not finish checking plugin-managed skills.", - "scanDepthLimit": "Orca reached its plugin scan depth limit before checking this folder.", - "scanEntryLimit": "Orca reached its plugin scan entry limit before checking the rest of this cache.", - "scanCandidateLimit": "Orca found more same-named skill folders than it can safely inspect.", - "scanManifestLimit": "Orca skipped this plugin manifest because it exceeded a safe limit.", - "scanOutsideRoot": "Orca skipped this plugin path because it points outside the plugin cache.", - "scanIoErrorWithCode": "Orca could not read this plugin path ({{value0}}).", - "scanIoError": "Orca could not read this plugin path.", - "scanIssueLimit": "Orca found too many skipped plugin folders to list individually.", - "unselectedForeignOne": "1 skill not installed by Orca is unselected.", - "unselectedForeignMany": "{{value0}} skills not installed by Orca are unselected." - }, - "SkillUpdateResultRows": { - "stillOutdated": "Still out of date after the update ran." + "finishedWithProblems": "Some skills need attention", + "repair": "An earlier update was interrupted. Update to repair it.", + "hideFolders": "Hide folders", + "showFolders": "Show folders", + "upToDateMany": "{{value0}} skills are up to date.", + "upToDateOne": "{{value0}} is up to date.", + "updatedMany": "Updated {{value0}} skills", + "updatedOne": "Updated 1 skill", + "updatedSkipped": "{{value0}} · {{value1}} skipped", + "currentOne": "1 skill is up to date", + "currentMany": "{{value0}} skills are up to date", + "noUpdates": "No updates available" }, + "SkillUpdateResultRows": {}, "SkillUpdateRow": { - "oneLocation": "1 location", - "manyLocations": "{{value0}} locations", - "notFromOrca": "Not from Orca", - "notFromOrcaSourceTip": "Installed from {{value0}}. Selecting it replaces it with Orca’s version.", - "notFromOrcaNoRecordTip": "No Orca install record for this copy. Selecting it replaces it with Orca’s version.", - "includeSkill": "Include {{value0}}" + "replaceWithOrca": "Replace with Orca's copy (keeps a backup)" }, "SkillFreshnessStatusPill": { "updateAvailable": "Update available", @@ -5530,6 +5478,34 @@ "placementSummaryParts": "Removes {{parts}} across {{roots}}.", "placementJoin": " and ", "retainedSource": "The skill itself stays at {{path}}." + }, + "skillPlanOpCopy": { + "busy": "Another install is still running", + "edited": "Skill edited outside Orca", + "install": "Install", + "installFor": "Install for {{value0}}", + "installed": "Installed", + "keep": "No change", + "leftAsIs": "This copy was kept as it is", + "link": "Link", + "linked": "Linked", + "needsAttention": "Needs attention", + "other": "A different skill uses this name", + "replace": "Replace", + "replacedWithBackup": "Replaced · Your copy was saved as a backup", + "sharedNotCurrent": "The shared copy isn't Orca's", + "unwritable": "Orca can't write to this folder", + "unreadable": "Orca can't read every file", + "update": "Update", + "updated": "Updated", + "write": "Install", + "keepLink": "Link, no change", + "repair": "Repair", + "sharedFolder": "Shared folder", + "updateSkipped": "Update skipped: {{value0}}", + "updateAvailable": "Update available", + "repairAvailable": "Repair available", + "installAvailable": "Install available" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9b521384c15..14e9fb6ee44 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4213,12 +4213,9 @@ "successOne": "Updated {{value0}}", "successMany": "Updated {{value0}} skills", "successAria": "Skills updated. Click to open details.", - "errorLabel": "Update failed", - "errorTooltip": "Skill update failed — click to see details", - "errorAria": "Skill update failed. Click to open details.", - "stoppingLabel": "Stopping update", - "stoppingTooltip": "Stopping the skill update…", - "stoppingAria": "Stopping the skill update. Click to open details." + "attentionAria": "Se omitieron algunas actualizaciones de skills. Haz clic para abrir los detalles.", + "attentionLabel": "Skills requieren atención", + "attentionTooltip": "Se omitieron algunas actualizaciones de skills — haz clic para ver los detalles" }, "NativeChatResumeStatusSegment": { "failedLabelOne": "1 chat no pudo reanudarse", @@ -4767,34 +4764,7 @@ "updateMany": "Actualizar skills", "description": "Actualiza {{value0}} para que los Agents sigan las instrucciones actuales de esta versión de Orca." }, - "SkillFreshnessRow": { - "statusUpdateAvailable": "Actualización disponible", - "statusCantUpdate": "No se puede actualizar", - "cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando de actualización la deja sin cambios.", - "chipCurrent": "Actual", - "chipInaccessible": "Inaccesible", - "chipDuplicate": "Duplicada", - "chipExternalLink": "Enlace externo", - "chipBrokenLink": "Enlace roto", - "chipReadOnly": "Solo lectura", - "chipInRepo": "En un repo", - "chipPluginCache": "Caché de plugin", - "tipCurrent": "La skill aquí ya está actualizada; la actualización no la cambiará.", - "tipInaccessible": "Orca no pudo leer la skill aquí (un error de permisos o de archivo), así que no puede comprobarla ni actualizarla.", - "tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando de actualización no puede alcanzarla. Elimínala para permitir las actualizaciones.", - "tipExternalLink": "Es un acceso directo que apunta fuera de las carpetas de skills de Orca; la actualización no lo seguirá.", - "tipBrokenLink": "Es un acceso directo a algo que ya no existe; puedes eliminarlo sin problema.", - "tipReadOnly": "La skill aquí está en una ubicación de solo lectura, así que no se puede actualizar hasta que cambies sus permisos.", - "tipInRepo": "La skill aquí vive dentro de un proyecto, no en tus skills globales; Orca solo actualiza las globales.", - "tipPluginCache": "La skill aquí la gestiona un plugin; actualiza el plugin en su lugar.", - "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", - "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", - "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", - "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", - "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", - "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.", - "skippedReasonDuplicate": "Es una copia aparte, así que la actualización no la alcanza: el comando solo actualiza la copia principal. Elimina esta copia y vuelve a instalar la skill para que esta ubicación siga a la principal." - }, + "SkillFreshnessRow": {}, "SkillFreshnessUpdateDialog": { "title": "Actualizar skills", "checking": "Comprobando las skills de Orca instaladas…", @@ -4802,41 +4772,26 @@ "updateOne": "Se puede actualizar 1 skill de forma segura", "updateMany": "Se pueden actualizar {{value0}} skills de forma segura", "success": "Todas las skills de Orca instaladas están actualizadas.", - "attention": "Algunas skills de Orca instaladas no se pueden actualizar automáticamente.", "checkNow": "Comprobar ahora", "close": "Cerrar", - "blockedOne": "1 skill can't be updated automatically.", - "blockedMany": "{{value0}} skills can't be updated automatically.", - "runningOne": "Updating 1 skill…", - "runningMany": "Updating {{value0}} skills…", - "runningDescription": "You can close this window — it keeps running in the background.", "progressAria": "Updating skills", - "updatedOne": "Updated 1 skill", - "updatedMany": "Updated {{value0}} skills", - "updatedPartial": "Updated {{value0}} of {{value1}} skills", "errorTitle": "The update didn't finish", - "retry": "Retry", - "copyCommand": "Copy command", - "copied": "Copied", - "showLog": "Show log", "updating": "Updating…", "updateActionOne": "Update 1 skill", "updateActionMany": "Update {{value0}} skills", "done": "Done", - "stop": "Stop", - "stopping": "Stopping…", - "stoppingHeadline": "Stopping the update…", - "scanIncomplete": "Orca no pudo terminar de comprobar los skills gestionados por plugins.", - "scanDepthLimit": "Orca alcanzó el límite de profundidad del análisis antes de comprobar esta carpeta.", - "scanEntryLimit": "Orca alcanzó el límite de entradas del análisis antes de comprobar el resto de esta caché.", - "scanCandidateLimit": "Orca encontró más carpetas de skills con el mismo nombre de las que puede inspeccionar de forma segura.", - "scanManifestLimit": "Orca omitió este manifiesto del plugin porque superaba un límite seguro.", - "scanOutsideRoot": "Orca omitió esta ruta del plugin porque apunta fuera de la caché de plugins.", - "scanIoErrorWithCode": "Orca no pudo leer esta ruta del plugin ({{value0}}).", - "scanIoError": "Orca no pudo leer esta ruta del plugin.", - "scanIssueLimit": "Orca encontró demasiadas carpetas de plugins omitidas para mostrarlas por separado.", - "unselectedForeignOne": "1 skill no instalada por Orca no está seleccionada.", - "unselectedForeignMany": "{{value0}} skills no instaladas por Orca no están seleccionadas." + "finishedWithProblems": "Algunas skills requieren atención", + "repair": "Una actualización anterior se interrumpió. Actualiza para repararla.", + "hideFolders": "Ocultar carpetas", + "showFolders": "Mostrar carpetas", + "upToDateMany": "{{value0}} skills están actualizadas.", + "upToDateOne": "{{value0}} está actualizada.", + "updatedMany": "Se actualizaron {{value0}} skills", + "updatedOne": "Se actualizó 1 skill", + "updatedSkipped": "{{value0}} · {{value1}} omitidas", + "currentOne": "1 skill está actualizada", + "currentMany": "{{value0}} skills están actualizadas", + "noUpdates": "No hay actualizaciones disponibles" }, "SkillFreshnessStatusPill": { "updateAvailable": "Actualización disponible", @@ -4847,16 +4802,9 @@ "checking": "De cheques...", "checkFailed": "Verificación fallida" }, - "SkillUpdateResultRows": { - "stillOutdated": "Still out of date after the update ran." - }, + "SkillUpdateResultRows": {}, "SkillUpdateRow": { - "oneLocation": "1 ubicación", - "manyLocations": "{{value0}} ubicaciones", - "notFromOrca": "No es de Orca", - "notFromOrcaSourceTip": "Instalada desde {{value0}}. Si la seleccionas, se reemplaza por la versión de Orca.", - "notFromOrcaNoRecordTip": "No hay registro de instalación de Orca para esta copia. Si la seleccionas, se reemplaza por la versión de Orca.", - "includeSkill": "Incluir {{value0}}" + "replaceWithOrca": "Reemplazar por la copia de Orca (guarda una copia de seguridad)" }, "host": { "local": "Esta máquina", @@ -5232,6 +5180,34 @@ "placementSummaryParts": "Elimina {{parts}} a través de {{roots}}.", "placementJoin": "y", "retainedSource": "La habilidad en sí permanece en {{path}}." + }, + "skillPlanOpCopy": { + "busy": "Otra instalación sigue en curso", + "edited": "Skill modificada fuera de Orca", + "install": "Instalar", + "installFor": "Instalar para {{value0}}", + "installed": "Instalada", + "keep": "Sin cambios", + "leftAsIs": "Esta copia se conservó tal como estaba", + "link": "Enlazar", + "linked": "Enlazada", + "needsAttention": "Requiere atención", + "other": "Otra skill usa este nombre", + "replace": "Reemplazar", + "replacedWithBackup": "Reemplazado · Tu copia se guardó como copia de seguridad", + "sharedNotCurrent": "La copia compartida no es de Orca", + "unwritable": "Orca no puede escribir en esta carpeta", + "unreadable": "Orca no puede leer todos los archivos", + "update": "Actualizar", + "updated": "Actualizada", + "write": "Instalar", + "keepLink": "Enlace, sin cambios", + "repair": "Reparar", + "sharedFolder": "Carpeta compartida", + "updateSkipped": "Actualización omitida: {{value0}}", + "updateAvailable": "Actualización disponible", + "repairAvailable": "Reparación disponible", + "installAvailable": "Instalación disponible" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index da7de4ec5f8..3c8e1cdbf96 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -4071,12 +4071,9 @@ "successOne": "{{value0}} mis à jour", "successMany": "Skills de {{value0}} mis à jour", "successAria": "Skills mis à jour. Cliquez pour ouvrir les détails.", - "errorLabel": "Échec de la mise à jour", - "errorTooltip": "Échec de la mise à jour des skills — cliquez pour voir les détails", - "errorAria": "Échec de la mise à jour des skills. Cliquez pour ouvrir les détails.", - "stoppingLabel": "Arrêt de la mise à jour", - "stoppingTooltip": "Arrêt de la mise à jour des skills…", - "stoppingAria": "Arrêt de la mise à jour des skills. Cliquez pour ouvrir les détails." + "attentionAria": "Certaines mises à jour de skills ont été ignorées. Cliquez pour ouvrir les détails.", + "attentionLabel": "Skills à vérifier", + "attentionTooltip": "Certaines mises à jour de skills ont été ignorées — cliquez pour voir les détails" }, "UpdateStatusSegment": { "5cd13105a3": "Échec de la mise à jour. Cliquez pour déplier.", @@ -4811,87 +4808,38 @@ "updateOne": "Mettre à jour le skill", "updateMany": "Mettre à jour les skills" }, - "SkillFreshnessRow": { - "statusUpdateAvailable": "Mise à jour disponible", - "statusCantUpdate": "Ignoré", - "cantUpdateReason": "Orca a exclu ce skill de la commande de mise à jour.", - "skippedReasonReadOnly": "Cette copie se trouve dans un emplacement en lecture seule, donc Orca l'a exclue de la mise à jour. Modifiez ses permissions pour permettre à Orca de la mettre à jour.", - "skippedReasonInaccessible": "Orca n'a pas pu lire cette copie, il a donc exclu le skill de la mise à jour.", - "skippedReasonInRepo": "C'est un skill de projet, pas un skill global — Orca ne met à jour que vos skills globaux, il a donc exclu celui-ci de la mise à jour.", - "skippedReasonPluginCache": "Un plugin gère ce skill, donc Orca l'a exclu de la mise à jour — mettez plutôt à jour le plugin.", - "skippedReasonExternalLink": "Cette copie est un raccourci pointant hors des dossiers de skills d'Orca, donc Orca l'a exclue de la mise à jour.", - "skippedReasonBrokenLink": "Cette copie est un raccourci vers quelque chose qui n'existe plus, donc Orca l'a exclue — vous pouvez la supprimer sans risque.", - "chipCurrent": "Actuel", - "chipInaccessible": "Inaccessible", - "chipDuplicate": "Doublon", - "chipExternalLink": "Lien externe", - "chipBrokenLink": "Lien cassé", - "chipReadOnly": "Lecture seule", - "chipInRepo": "Dans un dépôt", - "chipPluginCache": "Cache de plugins", - "tipCurrent": "Cette copie correspond à la version officielle actuelle.", - "tipInaccessible": "Orca n'a pas pu lire cette copie (erreur de permissions ou de fichier).", - "tipDuplicate": "Une copie séparée de ce skill, installée à part de la principale.", - "tipExternalLink": "Un raccourci pointant hors des dossiers de skills d'Orca.", - "tipBrokenLink": "Un raccourci vers quelque chose qui n'existe plus.", - "tipReadOnly": "Cette copie se trouve dans un emplacement en lecture seule.", - "tipInRepo": "Cette copie vit dans un projet, pas dans vos skills globaux.", - "tipPluginCache": "Cette copie est gérée par un plugin.", - "skippedReasonDuplicate": "Il s'agit d'une copie séparée : la mise à jour ne l'atteindra pas — la commande ne rafraîchit que la copie principale. Supprimez cette copie puis réinstallez le skill pour que cet emplacement suive le principal." - }, + "SkillFreshnessRow": {}, "SkillFreshnessUpdateDialog": { "title": "Mettre à jour les skills", "checking": "Vérification des skills Orca installés…", "none": "Aucun skill Orca installé trouvé.", "updateOne": "1 mise à jour disponible", "updateMany": "{{value0}} mises à jour disponibles", - "blockedOne": "1 skill ne peut pas être mis à jour automatiquement.", - "blockedMany": "{{value0}} skills ne peuvent pas être mis à jour automatiquement.", "success": "Tous les skills Orca installés sont à jour.", - "attention": "Certains skills Orca installés ont été exclus de la mise à jour.", - "runningOne": "Mise à jour d'1 skill…", - "runningMany": "Mise à jour des skills de {{value0}}…", - "runningDescription": "Vous pouvez fermer cette fenêtre — le traitement continue en arrière-plan.", "progressAria": "Mise à jour des skills", - "updatedOne": "1 skill mis à jour", - "updatedMany": "Skills de {{value0}} mis à jour", - "updatedPartial": "{{value0}} skills mis à jour sur {{value1}}", "errorTitle": "La mise à jour ne s'est pas terminée", - "retry": "Réessayer", - "copyCommand": "Copier la commande", - "copied": "Copied", - "showLog": "Afficher le journal", "updating": "Mise à jour…", "updateActionOne": "Mettre à jour 1 skill", "updateActionMany": "Mettre à jour {{value0}} skills", "checkNow": "Revérifier", "done": "Terminé", "close": "Fermer", - "stop": "Arrêter", - "stopping": "Arrêt…", - "stoppingHeadline": "Arrêt de la mise à jour…", - "scanIncomplete": "Orca n'a pas pu terminer la vérification des skills gérés par plugin.", - "scanDepthLimit": "Orca a atteint sa limite de profondeur d'analyse des plugins avant de vérifier ce dossier.", - "scanEntryLimit": "Orca a atteint sa limite d'entrées d'analyse des plugins avant de vérifier le reste de ce cache.", - "scanCandidateLimit": "Orca a trouvé plus de dossiers de skills homonymes qu'il ne peut en inspecter sans risque.", - "scanManifestLimit": "Orca a ignoré ce manifeste de plugin car il dépassait une limite de sécurité.", - "scanOutsideRoot": "Orca a ignoré ce chemin de plugin car il pointe hors du cache de plugins.", - "scanIoErrorWithCode": "Orca n'a pas pu lire ce chemin de plugin ({{value0}}).", - "scanIoError": "Orca n'a pas pu lire ce chemin de plugin.", - "scanIssueLimit": "Orca a trouvé trop de dossiers de plugins ignorés pour pouvoir les lister un par un.", - "unselectedForeignOne": "1 skill non installé par Orca n’est pas sélectionné.", - "unselectedForeignMany": "{{value0}} skills non installés par Orca ne sont pas sélectionnés." - }, - "SkillUpdateResultRows": { - "stillOutdated": "Toujours pas à jour après l'exécution de la mise à jour." + "finishedWithProblems": "Certains skills demandent votre attention", + "repair": "Une mise à jour précédente a été interrompue. Mettez à jour pour la réparer.", + "hideFolders": "Masquer les dossiers", + "showFolders": "Afficher les dossiers", + "upToDateMany": "{{value0}} skills sont à jour.", + "upToDateOne": "{{value0}} est à jour.", + "updatedMany": "{{value0}} skills mis à jour", + "updatedOne": "1 skill mis à jour", + "updatedSkipped": "{{value0}} · {{value1}} ignorées", + "currentOne": "1 skill est à jour", + "currentMany": "{{value0}} skills sont à jour", + "noUpdates": "Aucune mise à jour disponible" }, + "SkillUpdateResultRows": {}, "SkillUpdateRow": { - "oneLocation": "1 emplacement", - "manyLocations": "{{value0}} emplacements", - "notFromOrca": "Hors Orca", - "notFromOrcaSourceTip": "Installé depuis {{value0}}. Le sélectionner le remplace par la version d’Orca.", - "notFromOrcaNoRecordTip": "Aucune trace d’installation par Orca pour cette copie. La sélectionner la remplace par la version d’Orca.", - "includeSkill": "Inclure {{value0}}" + "replaceWithOrca": "Remplacer par la copie d’Orca (conserve une sauvegarde)" }, "SkillFreshnessStatusPill": { "updateAvailable": "Mise à jour disponible", @@ -5361,6 +5309,34 @@ "placementSummaryParts": "Supprime {{parts}} répartis sur {{roots}}.", "placementJoin": " et ", "retainedSource": "Le skill lui-même reste à {{path}}." + }, + "skillPlanOpCopy": { + "busy": "Une autre installation est toujours en cours", + "edited": "Skill modifié en dehors d’Orca", + "install": "Installer", + "installFor": "Installer pour {{value0}}", + "installed": "Installé", + "keep": "Aucun changement", + "leftAsIs": "Cette copie a été conservée telle quelle", + "link": "Lier", + "linked": "Lié", + "needsAttention": "Demande votre attention", + "other": "Un autre skill utilise ce nom", + "replace": "Remplacer", + "replacedWithBackup": "Remplacé · Votre copie a été sauvegardée", + "sharedNotCurrent": "La copie partagée n’est pas celle d’Orca", + "unwritable": "Orca ne peut pas écrire dans ce dossier", + "unreadable": "Orca ne peut pas lire tous les fichiers", + "update": "Mettre à jour", + "updated": "Mis à jour", + "write": "Installer", + "keepLink": "Lien, aucun changement", + "repair": "Réparer", + "sharedFolder": "Dossier partagé", + "updateSkipped": "Mise à jour ignorée : {{value0}}", + "updateAvailable": "Mise à jour disponible", + "repairAvailable": "Réparation disponible", + "installAvailable": "Installation disponible" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2d0d1d3a01c..26d5bc23087 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4203,12 +4203,9 @@ "successOne": "{{value0}} を更新しました", "successMany": "{{value0}} 件のスキルを更新しました", "successAria": "スキルを更新しました。クリックすると詳細を開きます。", - "errorLabel": "更新に失敗しました", - "errorTooltip": "スキルの更新に失敗しました — クリックすると詳細を表示します", - "errorAria": "スキルの更新に失敗しました。クリックすると詳細を開きます。", - "stoppingLabel": "更新を停止しています", - "stoppingTooltip": "スキルの更新を停止しています…", - "stoppingAria": "スキルの更新を停止しています。クリックすると詳細を開きます。" + "attentionAria": "一部のスキルの更新はスキップされました。クリックして詳細を開きます。", + "attentionLabel": "スキルに対応が必要", + "attentionTooltip": "一部のスキルの更新はスキップされました — クリックして詳細を表示" }, "RuntimeHostStatusRow": { "previous_connection_closed": "以前の接続が閉じられました", @@ -4757,34 +4754,7 @@ "updateMany": "スキルを更新", "description": "Agent がこのバージョンの Orca の最新の手順に従うように、{{value0}} を更新してください。" }, - "SkillFreshnessRow": { - "statusUpdateAvailable": "更新があります", - "statusCantUpdate": "スキップ済み", - "cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、更新コマンドは変更しません。", - "chipCurrent": "現在", - "chipInaccessible": "アクセス不可", - "chipDuplicate": "重複", - "chipExternalLink": "外部リンク", - "chipBrokenLink": "リンク切れ", - "chipReadOnly": "読み取り専用", - "chipInRepo": "リポジトリ内", - "chipPluginCache": "プラグインキャッシュ", - "tipCurrent": "ここのスキルはすでに最新です。更新しても変更されません。", - "tipInaccessible": "Orca はここのスキルを読み取れなかったため(権限またはファイルのエラー)、確認も更新もできません。", - "tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため 更新コマンドは到達できません。更新を許可するには削除してください。", - "tipExternalLink": "これは Orca のスキルフォルダーの外を指すショートカットです。更新では追跡されません。", - "tipBrokenLink": "これは存在しないものを指すショートカットです。削除しても問題ありません。", - "tipReadOnly": "ここのスキルは読み取り専用の場所にあるため、権限を変更するまで更新できません。", - "tipInRepo": "ここのスキルはグローバルスキルではなくプロジェクト内にあります。Orca はグローバルなものだけを更新します。", - "tipPluginCache": "ここのスキルはプラグインによって管理されています。代わりにプラグインを更新してください。", - "skippedReasonReadOnly": "このコピーは読み取り専用の場所にあるため、Orca は更新対象から除外しました。Orca が更新できるように、権限を変更してください。", - "skippedReasonInaccessible": "Orca はこのコピーを読み取れなかったため、このスキルを更新対象から除外しました。", - "skippedReasonInRepo": "これはグローバルスキルではなくプロジェクトスキルです。Orca はグローバルスキルのみを更新するため、更新対象から除外しました。", - "skippedReasonPluginCache": "このスキルはプラグインが管理しているため、Orca は更新対象から除外しました。代わりにプラグインを更新してください。", - "skippedReasonExternalLink": "このコピーは Orca のスキルフォルダー外を指すショートカットのため、Orca は更新対象から除外しました。", - "skippedReasonBrokenLink": "このコピーは存在しない対象へのショートカットのため、Orca は更新対象から除外しました。削除して問題ありません。", - "skippedReasonDuplicate": "これは別のコピーのため、更新は反映されません。コマンドはメインのコピーのみを更新します。このコピーを削除してからスキルを再インストールすると、この場所もメインに追従します。" - }, + "SkillFreshnessRow": {}, "SkillFreshnessUpdateDialog": { "title": "スキルを更新", "checking": "インストール済みの Orca スキルを確認中…", @@ -4792,41 +4762,26 @@ "updateOne": "1 件の更新があります", "updateMany": "{{value0}} 件の更新があります", "success": "インストール済みの Orca スキルはすべて最新です。", - "attention": "一部のインストール済み Orca スキルは自動的に更新できません。", "checkNow": "再確認", "close": "閉じる", - "blockedOne": "1 件のスキルは自動更新できません。", - "blockedMany": "{{value0}} 件のスキルは自動更新できません。", - "runningOne": "1 件のスキルを更新しています…", - "runningMany": "{{value0}} 件のスキルを更新しています…", - "runningDescription": "このウィンドウは閉じてかまいません。バックグラウンドで実行が続きます。", "progressAria": "スキルを更新しています", - "updatedOne": "1 件のスキルを更新しました", - "updatedMany": "{{value0}} 件のスキルを更新しました", - "updatedPartial": "{{value1}} 件中 {{value0}} 件のスキルを更新しました", "errorTitle": "更新が完了しませんでした", - "retry": "再試行", - "copyCommand": "コマンドをコピー", - "copied": "コピーしました", - "showLog": "ログを表示", "updating": "Updating…", "updateActionOne": "1 件のスキルを更新", "updateActionMany": "{{value0}} 件のスキルを更新", "done": "完了", - "stop": "停止", - "stopping": "Stopping…", - "stoppingHeadline": "更新を停止しています…", - "scanIncomplete": "Orca はプラグインで管理されるスキルの確認を完了できませんでした。", - "scanDepthLimit": "このフォルダーを確認する前に、プラグインスキャンの深度上限に達しました。", - "scanEntryLimit": "キャッシュの残りを確認する前に、プラグインスキャンのエントリ数上限に達しました。", - "scanCandidateLimit": "安全に確認できる数を超える同名のスキルフォルダーが見つかりました。", - "scanManifestLimit": "安全な上限を超えたため、このプラグインマニフェストをスキップしました。", - "scanOutsideRoot": "プラグインキャッシュの外部を指しているため、このプラグインパスをスキップしました。", - "scanIoErrorWithCode": "このプラグインパスを読み取れませんでした({{value0}})。", - "scanIoError": "このプラグインパスを読み取れませんでした。", - "scanIssueLimit": "スキップされたプラグインフォルダーが多すぎるため、個別に表示できません。", - "unselectedForeignOne": "Orca 以外からインストールされた 1 件のスキルは選択されていません。", - "unselectedForeignMany": "Orca 以外からインストールされた {{value0}} 件のスキルは選択されていません。" + "finishedWithProblems": "一部のスキルに対応が必要です", + "repair": "以前の更新が中断されました。更新すると修復されます。", + "hideFolders": "フォルダーを隠す", + "showFolders": "フォルダーを表示", + "upToDateMany": "{{value0}} 個のスキルは最新です。", + "upToDateOne": "{{value0}} は最新です。", + "updatedMany": "{{value0}} 個のスキルを更新しました", + "updatedOne": "1 個のスキルを更新しました", + "updatedSkipped": "{{value0}} · {{value1}} 件をスキップ", + "currentOne": "1 個のスキルは最新です", + "currentMany": "{{value0}} 個のスキルは最新です", + "noUpdates": "利用可能な更新はありません" }, "SkillFreshnessStatusPill": { "updateAvailable": "更新があります", @@ -4837,16 +4792,9 @@ "checking": "確認中…", "checkFailed": "チェックに失敗しました" }, - "SkillUpdateResultRows": { - "stillOutdated": "更新を実行しましたが、まだ最新ではありません。" - }, + "SkillUpdateResultRows": {}, "SkillUpdateRow": { - "oneLocation": "1 件の場所", - "manyLocations": "{{value0}} 件の場所", - "notFromOrca": "Orca 以外から導入", - "notFromOrcaSourceTip": "{{value0}} からインストールされています。選択すると Orca のバージョンに置き換えられます。", - "notFromOrcaNoRecordTip": "このコピーには Orca によるインストール記録がありません。選択すると Orca のバージョンに置き換えられます。", - "includeSkill": "{{value0}} を含める" + "replaceWithOrca": "Orca のコピーに置き換える(バックアップを保持)" }, "host": { "local": "このマシン", @@ -5222,6 +5170,34 @@ "placementSummaryParts": "{{roots}} 間の {{parts}} を削除します。", "placementJoin": " と ", "retainedSource": "スキル自体は{{path}}のままです。" + }, + "skillPlanOpCopy": { + "busy": "別のインストールがまだ実行中です", + "edited": "スキルが Orca の外部で編集されています", + "install": "インストール", + "installFor": "{{value0}} にインストール", + "installed": "インストール済み", + "keep": "変更なし", + "leftAsIs": "このコピーはそのまま保持されました", + "link": "リンク", + "linked": "リンク済み", + "needsAttention": "対応が必要", + "other": "別のスキルがこの名前を使用しています", + "replace": "置き換え", + "replacedWithBackup": "置き換えました · 元のコピーはバックアップとして保存されました", + "sharedNotCurrent": "共有コピーは Orca のものではありません", + "unwritable": "Orca はこのフォルダーに書き込めません", + "unreadable": "Orca はすべてのファイルを読み取れません", + "update": "更新", + "updated": "更新済み", + "write": "インストール", + "keepLink": "リンク、変更なし", + "repair": "修復", + "sharedFolder": "共有フォルダー", + "updateSkipped": "更新をスキップ: {{value0}}", + "updateAvailable": "更新があります", + "repairAvailable": "修復できます", + "installAvailable": "インストールできます" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f2a64aa7a4a..8d4a817ed99 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4203,12 +4203,9 @@ "successOne": "Updated {{value0}}", "successMany": "Updated {{value0}} skills", "successAria": "Skills updated. Click to open details.", - "errorLabel": "Update failed", - "errorTooltip": "Skill update failed — click to see details", - "errorAria": "Skill update failed. Click to open details.", - "stoppingLabel": "Stopping update", - "stoppingTooltip": "Stopping the skill update…", - "stoppingAria": "Stopping the skill update. Click to open details." + "attentionAria": "일부 스킬 업데이트를 건너뛰었습니다. 클릭하여 세부 정보를 여세요.", + "attentionLabel": "스킬 확인 필요", + "attentionTooltip": "일부 스킬 업데이트를 건너뛰었습니다 — 클릭하여 세부 정보 보기" }, "RuntimeHostStatusRow": { "previous_connection_closed": "이전 연결이 종료되었습니다.", @@ -4757,34 +4754,7 @@ "updateMany": "스킬 업데이트", "description": "Agents가 이 버전의 Orca에 대한 최신 지침을 따르도록 {{value0}}을(를) 업데이트하세요." }, - "SkillFreshnessRow": { - "statusUpdateAvailable": "업데이트 있음", - "statusCantUpdate": "업데이트할 수 없음", - "cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, 업데이트 명령이 변경하지 않습니다.", - "chipCurrent": "최신", - "chipInaccessible": "액세스 불가", - "chipDuplicate": "중복", - "chipExternalLink": "외부 링크", - "chipBrokenLink": "깨진 링크", - "chipReadOnly": "읽기 전용", - "chipInRepo": "저장소 내", - "chipPluginCache": "플러그인 캐시", - "tipCurrent": "여기 있는 스킬은 이미 최신이므로 업데이트해도 변경되지 않습니다.", - "tipInaccessible": "Orca가 여기 있는 스킬을 읽지 못해(권한 또는 파일 오류) 확인하거나 업데이트할 수 없습니다.", - "tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 업데이트 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.", - "tipExternalLink": "Orca의 스킬 폴더 바깥을 가리키는 바로 가기입니다. 업데이트가 이를 따라가지 않습니다.", - "tipBrokenLink": "더 이상 존재하지 않는 대상을 가리키는 바로 가기입니다. 삭제해도 됩니다.", - "tipReadOnly": "여기 있는 스킬은 읽기 전용 위치에 있어 권한을 변경해야 업데이트할 수 있습니다.", - "tipInRepo": "여기 있는 스킬은 전역 스킬이 아니라 프로젝트 안에 있습니다. Orca는 전역 스킬만 업데이트합니다.", - "tipPluginCache": "여기 있는 스킬은 플러그인이 관리합니다. 대신 플러그인을 업데이트하세요.", - "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", - "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", - "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", - "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", - "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", - "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.", - "skippedReasonDuplicate": "별도의 사본이라 업데이트가 적용되지 않습니다. 명령은 기본 사본만 새로 고칩니다. 이 사본을 제거한 뒤 스킬을 다시 설치하면 이 위치도 기본 사본을 따릅니다." - }, + "SkillFreshnessRow": {}, "SkillFreshnessUpdateDialog": { "title": "스킬 업데이트", "checking": "설치된 Orca 스킬을 확인하는 중…", @@ -4792,41 +4762,26 @@ "updateOne": "스킬 1개를 안전하게 업데이트할 수 있습니다", "updateMany": "스킬 {{value0}}개를 안전하게 업데이트할 수 있습니다", "success": "설치된 Orca 스킬이 모두 최신 상태입니다.", - "attention": "일부 설치된 Orca 스킬은 자동으로 업데이트할 수 없습니다.", "checkNow": "지금 확인", "close": "닫기", - "blockedOne": "1 skill can't be updated automatically.", - "blockedMany": "{{value0}} skills can't be updated automatically.", - "runningOne": "Updating 1 skill…", - "runningMany": "Updating {{value0}} skills…", - "runningDescription": "You can close this window — it keeps running in the background.", "progressAria": "Updating skills", - "updatedOne": "Updated 1 skill", - "updatedMany": "Updated {{value0}} skills", - "updatedPartial": "Updated {{value0}} of {{value1}} skills", "errorTitle": "The update didn't finish", - "retry": "Retry", - "copyCommand": "Copy command", - "copied": "Copied", - "showLog": "Show log", "updating": "Updating…", "updateActionOne": "Update 1 skill", "updateActionMany": "Update {{value0}} skills", "done": "Done", - "stop": "Stop", - "stopping": "Stopping…", - "stoppingHeadline": "Stopping the update…", - "scanIncomplete": "Orca가 플러그인으로 관리되는 스킬 확인을 완료하지 못했습니다.", - "scanDepthLimit": "이 폴더를 확인하기 전에 플러그인 검사 깊이 제한에 도달했습니다.", - "scanEntryLimit": "이 캐시의 나머지 항목을 확인하기 전에 플러그인 검사 항목 수 제한에 도달했습니다.", - "scanCandidateLimit": "안전하게 검사할 수 있는 수보다 많은 동명 스킬 폴더가 발견되었습니다.", - "scanManifestLimit": "안전한 제한을 초과한 플러그인 매니페스트를 건너뛰었습니다.", - "scanOutsideRoot": "플러그인 캐시 외부를 가리키는 플러그인 경로를 건너뛰었습니다.", - "scanIoErrorWithCode": "이 플러그인 경로를 읽지 못했습니다({{value0}}).", - "scanIoError": "이 플러그인 경로를 읽지 못했습니다.", - "scanIssueLimit": "건너뛴 플러그인 폴더가 너무 많아 개별적으로 표시할 수 없습니다.", - "unselectedForeignOne": "Orca에서 설치하지 않은 스킬 1개는 선택되지 않았습니다.", - "unselectedForeignMany": "Orca에서 설치하지 않은 스킬 {{value0}}개는 선택되지 않았습니다." + "finishedWithProblems": "일부 스킬에 확인이 필요합니다", + "repair": "이전 업데이트가 중단되었습니다. 업데이트하면 복구됩니다.", + "hideFolders": "폴더 숨기기", + "showFolders": "폴더 보기", + "upToDateMany": "스킬 {{value0}}개가 최신 상태입니다.", + "upToDateOne": "{{value0}}은(는) 최신 상태입니다.", + "updatedMany": "스킬 {{value0}}개를 업데이트했습니다", + "updatedOne": "스킬 1개를 업데이트했습니다", + "updatedSkipped": "{{value0}} · {{value1}}개 건너뜀", + "currentOne": "스킬 1개가 최신 상태입니다", + "currentMany": "스킬 {{value0}}개가 최신 상태입니다", + "noUpdates": "사용 가능한 업데이트가 없습니다" }, "SkillFreshnessStatusPill": { "updateAvailable": "업데이트 가능", @@ -4837,16 +4792,9 @@ "checking": "확인 중...", "checkFailed": "확인 실패" }, - "SkillUpdateResultRows": { - "stillOutdated": "Still out of date after the update ran." - }, + "SkillUpdateResultRows": {}, "SkillUpdateRow": { - "oneLocation": "위치 1개", - "manyLocations": "위치 {{value0}}개", - "notFromOrca": "Orca에서 설치하지 않음", - "notFromOrcaSourceTip": "{{value0}}에서 설치되었습니다. 선택하면 Orca 버전으로 교체됩니다.", - "notFromOrcaNoRecordTip": "이 사본에는 Orca 설치 기록이 없습니다. 선택하면 Orca 버전으로 교체됩니다.", - "includeSkill": "{{value0}} 포함" + "replaceWithOrca": "Orca 사본으로 교체(백업 보관)" }, "host": { "local": "이 컴퓨터", @@ -5222,6 +5170,34 @@ "placementSummaryParts": "{{roots}}에서 {{parts}}을(를) 제거합니다.", "placementJoin": "그리고", "retainedSource": "스킬 자체는 {{path}}에 유지됩니다." + }, + "skillPlanOpCopy": { + "busy": "다른 설치가 아직 진행 중입니다", + "edited": "Orca 외부에서 스킬이 수정되었습니다", + "install": "설치", + "installFor": "{{value0}}에 설치", + "installed": "설치됨", + "keep": "변경 없음", + "leftAsIs": "이 사본은 원래 상태로 유지되었습니다", + "link": "연결", + "linked": "연결됨", + "needsAttention": "확인 필요", + "other": "다른 스킬이 이 이름을 사용합니다", + "replace": "교체", + "replacedWithBackup": "교체됨 · 기존 사본이 백업으로 저장되었습니다", + "sharedNotCurrent": "공유 사본이 Orca의 것이 아닙니다", + "unwritable": "Orca가 이 폴더에 쓸 수 없습니다", + "unreadable": "Orca가 모든 파일을 읽을 수 없습니다", + "update": "업데이트", + "updated": "업데이트됨", + "write": "설치", + "keepLink": "링크, 변경 없음", + "repair": "복구", + "sharedFolder": "공유 폴더", + "updateSkipped": "업데이트 건너뜀: {{value0}}", + "updateAvailable": "업데이트 가능", + "repairAvailable": "복구 가능", + "installAvailable": "설치 가능" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index eee37c0a632..9a22ca95a48 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -2246,12 +2246,9 @@ "successOne": "{{value0}} atualizada", "successMany": "{{value0}} skills atualizadas", "successAria": "Skills atualizadas. Clique para abrir os detalhes.", - "errorLabel": "Falha na atualização", - "errorTooltip": "Falha na atualização da skill — clique para ver os detalhes", - "errorAria": "Falha na atualização da skill. Clique para abrir os detalhes.", - "stoppingLabel": "Interrompendo atualização", - "stoppingTooltip": "Interrompendo a atualização da skill…", - "stoppingAria": "Interrompendo a atualização da skill. Clique para abrir os detalhes." + "attentionAria": "Algumas atualizações de skills foram ignoradas. Clique para abrir os detalhes.", + "attentionLabel": "Skills precisam de atenção", + "attentionTooltip": "Algumas atualizações de skills foram ignoradas — clique para ver os detalhes" }, "UpdateStatusSegment": { "5cd13105a3": "Falha na atualização. Clique para expandir.", @@ -12676,71 +12673,29 @@ "none": "Nenhuma skill do Orca instalada encontrada.", "updateOne": "1 atualização disponível", "updateMany": "{{value0}} atualizações disponíveis", - "blockedOne": "1 skill não pode ser atualizada automaticamente.", - "blockedMany": "{{value0}} skills não podem ser atualizadas automaticamente.", "success": "Todas as skills do Orca instaladas estão atualizadas.", - "attention": "Algumas skills do Orca instaladas foram deixadas de fora da atualização.", - "runningOne": "Atualizando 1 skill…", - "runningMany": "Atualizando {{value0}} skills…", - "runningDescription": "Você pode fechar esta janela — ela continua em execução em segundo plano.", "progressAria": "Atualizando skills", - "updatedOne": "1 skill atualizada", - "updatedMany": "{{value0}} skills atualizadas", - "updatedPartial": "{{value0}} de {{value1}} skills atualizadas", "errorTitle": "A atualização não terminou", - "retry": "Tentar novamente", - "copyCommand": "Copiar comando", - "copied": "Copiado", - "showLog": "Mostrar log", "updating": "Atualizando…", "updateActionOne": "Atualizar 1 skill", "updateActionMany": "Atualizar {{value0}} skills", "checkNow": "Verificar novamente", "done": "Concluído", "close": "Fechar", - "stop": "Parar", - "stopping": "Parando…", - "stoppingHeadline": "Parando a atualização…", - "scanIncomplete": "O Orca não conseguiu terminar de verificar as skills gerenciadas por plugin.", - "scanDepthLimit": "O Orca atingiu seu limite de profundidade de varredura de plugin antes de verificar esta pasta.", - "scanEntryLimit": "O Orca atingiu seu limite de entradas de varredura de plugin antes de verificar o restante deste cache.", - "scanCandidateLimit": "O Orca encontrou mais pastas de skill com o mesmo nome do que pode inspecionar com segurança.", - "scanManifestLimit": "O Orca ignorou este manifesto de plugin porque ele excedeu um limite seguro.", - "scanOutsideRoot": "O Orca ignorou este caminho de plugin porque ele aponta para fora do cache de plugins.", - "scanIoErrorWithCode": "O Orca não conseguiu ler este caminho de plugin ({{value0}}).", - "scanIoError": "O Orca não conseguiu ler este caminho de plugin.", - "scanIssueLimit": "O Orca encontrou pastas de plugin ignoradas demais para listar individualmente.", - "unselectedForeignOne": "1 habilidade não instalada pelo Orca está desmarcada.", - "unselectedForeignMany": "{{value0}} habilidades não instaladas pelo Orca estão desmarcadas." - }, - "SkillFreshnessRow": { - "statusUpdateAvailable": "Atualização disponível", - "statusCantUpdate": "Ignorada", - "cantUpdateReason": "O Orca deixou esta skill de fora do comando de atualização.", - "skippedReasonReadOnly": "Esta cópia está em um local somente leitura, então o Orca a deixou de fora da atualização. Altere suas permissões para permitir que o Orca a atualize.", - "skippedReasonInaccessible": "O Orca não conseguiu ler esta cópia, então deixou a skill de fora da atualização.", - "skippedReasonInRepo": "Esta é uma skill de projeto, não uma global — o Orca só atualiza suas skills globais, então deixou esta de fora da atualização.", - "skippedReasonPluginCache": "Um plugin gerencia esta skill, então o Orca a deixou de fora da atualização — atualize o plugin em vez disso.", - "skippedReasonExternalLink": "Esta cópia é um atalho que aponta para fora das pastas de skills do Orca, então o Orca a deixou de fora da atualização.", - "skippedReasonBrokenLink": "Esta cópia é um atalho para algo que não existe mais, então o Orca a deixou de fora — você pode excluí-la com segurança.", - "chipCurrent": "Atual", - "chipInaccessible": "Inacessível", - "chipDuplicate": "Duplicada", - "chipExternalLink": "Link externo", - "chipBrokenLink": "Link quebrado", - "chipReadOnly": "Somente leitura", - "chipInRepo": "Em um repositório", - "chipPluginCache": "Cache de plugin", - "tipCurrent": "Esta cópia corresponde à versão oficial atual.", - "tipInaccessible": "O Orca não conseguiu ler esta cópia (um erro de permissão ou de arquivo).", - "tipDuplicate": "Uma cópia separada desta skill, instalada além da principal.", - "tipExternalLink": "Um atalho que aponta para fora das pastas de skills do Orca.", - "tipBrokenLink": "Um atalho para algo que não existe mais.", - "tipReadOnly": "Esta cópia está em um local somente leitura.", - "tipInRepo": "Esta cópia vive dentro de um projeto, não nas suas skills globais.", - "tipPluginCache": "Esta cópia é gerenciada por um plugin.", - "skippedReasonDuplicate": "Esta é uma cópia separada, então a atualização não vai alcançá-la — o comando só atualiza a cópia principal. Remova esta cópia e reinstale a skill para que este local acompanhe a principal." + "finishedWithProblems": "Algumas skills precisam de atenção", + "repair": "Uma atualização anterior foi interrompida. Atualize para repará-la.", + "hideFolders": "Ocultar pastas", + "showFolders": "Mostrar pastas", + "upToDateMany": "{{value0}} skills estão atualizadas.", + "upToDateOne": "{{value0}} está atualizada.", + "updatedMany": "{{value0}} skills atualizadas", + "updatedOne": "1 skill atualizada", + "updatedSkipped": "{{value0}} · {{value1}} ignoradas", + "currentOne": "1 skill está atualizada", + "currentMany": "{{value0}} skills estão atualizadas", + "noUpdates": "Nenhuma atualização disponível" }, + "SkillFreshnessRow": {}, "SkillShareReviewContent": { "e3caf6baeb": "Revogar este link bloqueia o acesso futuro. Isso não remove cópias já instaladas nas máquinas dos destinatários.", "6d6233a3a4": "Copiar link", @@ -13125,12 +13080,7 @@ "unlisted": "Não listado — apenas pessoas com o link podem abri-lo." }, "SkillUpdateRow": { - "oneLocation": "1 local", - "manyLocations": "{{value0}} locais", - "notFromOrca": "Não é do Orca", - "notFromOrcaSourceTip": "Instalada de {{value0}}. Selecioná-la a substitui pela versão do Orca.", - "notFromOrcaNoRecordTip": "Não há registro de instalação pelo Orca para esta cópia. Selecioná-la a substitui pela versão do Orca.", - "includeSkill": "Incluir {{value0}}" + "replaceWithOrca": "Substituir pela cópia do Orca (mantém um backup)" }, "SkillCard": { "d25a1b8ae6": "Compartilhar skill", @@ -13160,9 +13110,7 @@ "loading": "Carregando links compartilhados…", "noMatches": "Nenhum link corresponde a esta busca." }, - "SkillUpdateResultRows": { - "stillOutdated": "Ainda desatualizada depois que a atualização foi executada." - }, + "SkillUpdateResultRows": {}, "SkillManagedInstallList": { "86c76cb262": "{{count}} pacote de skill" }, @@ -13174,6 +13122,34 @@ }, "sources": { "heading": "Pastas de skills" + }, + "skillPlanOpCopy": { + "busy": "Outra instalação ainda está em andamento", + "edited": "Skill editada fora do Orca", + "install": "Instalar", + "installFor": "Instalar para {{value0}}", + "installed": "Instalada", + "keep": "Sem alterações", + "leftAsIs": "Esta cópia foi mantida como estava", + "link": "Vincular", + "linked": "Vinculada", + "needsAttention": "Precisa de atenção", + "other": "Outra skill usa este nome", + "replace": "Substituir", + "replacedWithBackup": "Substituído · Sua cópia foi salva como backup", + "sharedNotCurrent": "A cópia compartilhada não é do Orca", + "unwritable": "O Orca não consegue gravar nesta pasta", + "unreadable": "O Orca não consegue ler todos os arquivos", + "update": "Atualizar", + "updated": "Atualizada", + "write": "Instalar", + "keepLink": "Link, sem alterações", + "repair": "Reparar", + "sharedFolder": "Pasta compartilhada", + "updateSkipped": "Atualização ignorada: {{value0}}", + "updateAvailable": "Atualização disponível", + "repairAvailable": "Reparo disponível", + "installAvailable": "Instalação disponível" } }, "automations": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 072f078d5cb..2871ea73442 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4203,12 +4203,9 @@ "successOne": "Updated {{value0}}", "successMany": "Updated {{value0}} skills", "successAria": "技能已更新。点击查看详情。", - "errorLabel": "Update failed", - "errorTooltip": "技能更新失败 — 点击查看详情", - "errorAria": "技能更新失败。点击查看详情。", - "stoppingLabel": "Stopping update", - "stoppingTooltip": "正在停止技能更新…", - "stoppingAria": "正在停止技能更新。点击查看详情。" + "attentionAria": "已跳过部分技能更新。点击打开详情。", + "attentionLabel": "技能需要处理", + "attentionTooltip": "已跳过部分技能更新 — 点击查看详情" }, "RuntimeHostStatusRow": { "previous_connection_closed": "之前的连接关闭了", @@ -4757,34 +4754,7 @@ "updateMany": "更新技能", "description": "更新 {{value0}},让智能体遵循此版本 Orca 的最新说明。" }, - "SkillFreshnessRow": { - "statusUpdateAvailable": "有更新", - "statusCantUpdate": "无法更新", - "cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 更新命令不会更改它。", - "chipCurrent": "最新", - "chipInaccessible": "无法访问", - "chipDuplicate": "重复", - "chipExternalLink": "外部链接", - "chipBrokenLink": "失效链接", - "chipReadOnly": "只读", - "chipInRepo": "在仓库中", - "chipPluginCache": "插件缓存", - "tipCurrent": "这里的技能已是最新,更新不会更改它。", - "tipInaccessible": "Orca 无法读取这里的技能(权限或文件错误),因此无法检查或更新它。", - "tipDuplicate": "此技能除主副本外还安装在这里,因此 更新命令无法访问它。请移除它以允许更新。", - "tipExternalLink": "这是一个指向 Orca 技能文件夹之外的快捷方式;更新不会跟随它。", - "tipBrokenLink": "这是一个指向已不存在内容的快捷方式,可以安全删除。", - "tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。", - "tipInRepo": "这里的技能位于某个项目内,而非全局技能;Orca 只更新全局技能。", - "tipPluginCache": "这里的技能由插件管理;请改为更新插件。", - "skippedReasonReadOnly": "此副本位于只读位置,因此 Orca 未将其纳入更新。请更改其权限,让 Orca 更新它。", - "skippedReasonInaccessible": "Orca 无法读取此副本,因此未将该技能纳入更新。", - "skippedReasonInRepo": "这是项目技能而非全局技能 — Orca 只更新您的全局技能,因此将其排除在更新之外。", - "skippedReasonPluginCache": "此技能由插件管理,因此 Orca 未将其纳入更新 — 请改为更新插件。", - "skippedReasonExternalLink": "此副本是指向 Orca 技能文件夹之外的快捷方式,因此 Orca 未将其纳入更新。", - "skippedReasonBrokenLink": "此副本是指向已不存在内容的快捷方式,因此 Orca 将其排除 — 您可以放心删除。", - "skippedReasonDuplicate": "这是独立的副本,因此更新不会触及它 — 该命令只刷新主副本。请删除此副本并重新安装技能,使此位置跟随主副本。" - }, + "SkillFreshnessRow": {}, "SkillFreshnessUpdateDialog": { "title": "更新技能", "checking": "正在检查已安装的 Orca 技能…", @@ -4792,41 +4762,26 @@ "updateOne": "可以安全更新 1 个技能", "updateMany": "可以安全更新 {{value0}} 个技能", "success": "所有已安装的 Orca 技能均为最新。", - "attention": "部分已安装的 Orca 技能无法自动更新。", "checkNow": "立即检查", "close": "关闭", - "blockedOne": "有 1 个技能无法自动更新。", - "blockedMany": "有 {{value0}} 个技能无法自动更新。", - "runningOne": "Updating 1 skill…", - "runningMany": "Updating {{value0}} skills…", - "runningDescription": "您可以关闭此窗口 — 更新会在后台继续运行。", "progressAria": "Updating skills", - "updatedOne": "Updated 1 skill", - "updatedMany": "Updated {{value0}} skills", - "updatedPartial": "已更新 {{value1}} 个技能中的 {{value0}} 个", "errorTitle": "更新未完成", - "retry": "Retry", - "copyCommand": "Copy command", - "copied": "Copied", - "showLog": "Show log", "updating": "Updating…", "updateActionOne": "Update 1 skill", "updateActionMany": "Update {{value0}} skills", "done": "Done", - "stop": "Stop", - "stopping": "Stopping…", - "stoppingHeadline": "正在停止更新…", - "scanIncomplete": "Orca 无法完成对插件管理技能的检查。", - "scanDepthLimit": "在检查此文件夹之前,Orca 已达到插件扫描深度上限。", - "scanEntryLimit": "在检查此缓存的其余内容之前,Orca 已达到插件扫描条目数上限。", - "scanCandidateLimit": "Orca 发现的同名技能文件夹数量超过了可安全检查的上限。", - "scanManifestLimit": "此插件清单超出安全限制,Orca 已将其跳过。", - "scanOutsideRoot": "此插件路径指向插件缓存之外,Orca 已将其跳过。", - "scanIoErrorWithCode": "Orca 无法读取此插件路径({{value0}})。", - "scanIoError": "Orca 无法读取此插件路径。", - "scanIssueLimit": "被跳过的插件文件夹过多,Orca 无法逐一列出。", - "unselectedForeignOne": "有 1 个非 Orca 安装的技能未选中。", - "unselectedForeignMany": "有 {{value0}} 个非 Orca 安装的技能未选中。" + "finishedWithProblems": "部分技能需要处理", + "repair": "之前的更新被中断。更新即可修复。", + "hideFolders": "隐藏文件夹", + "showFolders": "显示文件夹", + "upToDateMany": "{{value0}} 个技能已是最新。", + "upToDateOne": "{{value0}} 已是最新。", + "updatedMany": "已更新 {{value0}} 个技能", + "updatedOne": "已更新 1 个技能", + "updatedSkipped": "{{value0}} · 已跳过 {{value1}} 处", + "currentOne": "1 个技能已是最新", + "currentMany": "{{value0}} 个技能已是最新", + "noUpdates": "没有可用更新" }, "SkillFreshnessStatusPill": { "updateAvailable": "有可用更新", @@ -4837,16 +4792,9 @@ "checking": "检查中...", "checkFailed": "检查失败" }, - "SkillUpdateResultRows": { - "stillOutdated": "更新运行后仍为过期状态。" - }, + "SkillUpdateResultRows": {}, "SkillUpdateRow": { - "oneLocation": "1 个位置", - "manyLocations": "{{value0}} 个位置", - "notFromOrca": "非 Orca 安装", - "notFromOrcaSourceTip": "安装自 {{value0}}。选中后会替换为 Orca 的版本。", - "notFromOrcaNoRecordTip": "此副本没有 Orca 安装记录。选中后会替换为 Orca 的版本。", - "includeSkill": "包含 {{value0}}" + "replaceWithOrca": "替换为 Orca 的副本(保留备份)" }, "host": { "local": "此机器", @@ -5222,6 +5170,34 @@ "supportingFilesDescription": "包括主要说明之外的可读参考文件。", "runnableFilesDescription": "包括智能体可以使用您的访问权限运行的脚本。", "binaryFilesDescription": "包括不透明资产和可执行二进制文件。" + }, + "skillPlanOpCopy": { + "busy": "另一个安装仍在运行", + "edited": "技能已在 Orca 外部编辑", + "install": "安装", + "installFor": "为 {{value0}} 安装", + "installed": "已安装", + "keep": "无变化", + "leftAsIs": "此副本已保留原样", + "link": "链接", + "linked": "已链接", + "needsAttention": "需要处理", + "other": "另一个技能使用此名称", + "replace": "替换", + "replacedWithBackup": "已替换 · 你的副本已保存为备份", + "sharedNotCurrent": "共享副本不是 Orca 的", + "unwritable": "Orca 无法写入此文件夹", + "unreadable": "Orca 无法读取所有文件", + "update": "更新", + "updated": "已更新", + "write": "安装", + "keepLink": "链接,无变化", + "repair": "修复", + "sharedFolder": "共享文件夹", + "updateSkipped": "已跳过更新:{{value0}}", + "updateAvailable": "有可用更新", + "repairAvailable": "可修复", + "installAvailable": "可安装" } }, "sidebar": { diff --git a/src/renderer/src/lib/agent-skill-nav-install-status.test.ts b/src/renderer/src/lib/agent-skill-nav-install-status.test.ts index cacc6144687..d629dbd5825 100644 --- a/src/renderer/src/lib/agent-skill-nav-install-status.test.ts +++ b/src/renderer/src/lib/agent-skill-nav-install-status.test.ts @@ -1,10 +1,7 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' -import type { - SkillFreshnessInstallation, - SkillFreshnessInventory, - SkillFreshnessStatus -} from '../../../shared/skill-freshness' +import type { BundledSkillOp, BundledSkillPlan } from '../../../shared/bundled-skill-plan' +import { planOf, sharedEntry } from '../components/skills/bundled-skill-plan-fixtures.test-support' import type { DiscoveredSkill } from '../../../shared/skills' import { getAgentSkillNavInstallStatus, @@ -28,41 +25,18 @@ function skill(name: string): DiscoveredSkill { } } -function placement(name: string, status: SkillFreshnessStatus): SkillFreshnessInstallation { - const unresolvedPath = path.join('home', 'test', '.agents', 'skills', name) - return { - id: name, - name, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath, - resolvedPath: unresolvedPath, - physicalIdentity: name, - topology: 'canonical-copy', - status, - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null - } -} - -function inventory( - installations: SkillFreshnessInstallation[], - eligibleUpdateNames: string[] = [] -): SkillFreshnessInventory { - return { schemaVersion: 1, installations, eligibleUpdateNames, scanIssues: [], scannedAt: 1 } +function plan(...skills: [string, BundledSkillOp['kind']][]): BundledSkillPlan { + return planOf(skills.map(([name, kind]) => sharedEntry(name, kind))) } describe('getAgentSkillNavInstallStatus', () => { - it('keeps loading and missing states ahead of freshness', () => { + it('keeps loading and missing states ahead of the plan', () => { expect( getAgentSkillNavInstallStatus({ name: 'orca-linear', installed: true, loading: true, - inventory: inventory([placement('orca-linear', 'current')]) + plan: plan(['orca-linear', 'keep']) }) ).toBe('checking') expect( @@ -70,7 +44,7 @@ describe('getAgentSkillNavInstallStatus', () => { name: 'orca-linear', installed: false, loading: false, - inventory: null + plan: null }) ).toBe('install') }) @@ -85,7 +59,7 @@ describe('getLinearAgentSkillNavInstallStatus', () => { skills, installed: true, loading: false, - inventory: inventory([placement('orca-linear', 'current')]) + plan: plan(['orca-linear', 'keep']) }) ).toBe('up-to-date') expect( @@ -93,7 +67,7 @@ describe('getLinearAgentSkillNavInstallStatus', () => { skills, installed: true, loading: false, - inventory: inventory([placement('orca-linear', 'outdated')], ['orca-linear']) + plan: plan(['orca-linear', 'update']) }) ).toBe('update-available') }) @@ -104,32 +78,29 @@ describe('getLinearAgentSkillNavInstallStatus', () => { skills: [skill('linear-tickets')], installed: true, loading: false, - inventory: inventory([placement('linear-tickets', 'current')]) + plan: plan(['linear-tickets', 'keep']) }) ).toBe('up-to-date') }) - it('prefers canonical freshness when both names are installed', () => { + it('prefers the plan for the canonical name when both names are installed', () => { expect( getLinearAgentSkillNavInstallStatus({ skills: [skill('orca-linear'), skill('linear-tickets')], installed: true, loading: false, - inventory: inventory( - [placement('orca-linear', 'current'), placement('linear-tickets', 'outdated')], - ['linear-tickets'] - ) + plan: plan(['orca-linear', 'keep'], ['linear-tickets', 'update']) }) ).toBe('up-to-date') }) - it('falls back to presence-only status when freshness does not apply', () => { + it('falls back to presence-only status when no plan applies', () => { expect( getLinearAgentSkillNavInstallStatus({ skills: [skill('orca-linear')], installed: true, loading: false, - inventory: null + plan: null }) ).toBe('installed') }) diff --git a/src/renderer/src/lib/agent-skill-nav-install-status.ts b/src/renderer/src/lib/agent-skill-nav-install-status.ts index b324e4add36..c62c36218fe 100644 --- a/src/renderer/src/lib/agent-skill-nav-install-status.ts +++ b/src/renderer/src/lib/agent-skill-nav-install-status.ts @@ -1,4 +1,4 @@ -import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import type { BundledSkillPlan } from '../../../shared/bundled-skill-plan' import type { DiscoveredSkill } from '../../../shared/skills' import type { SettingsNavInstallStatus } from './settings-navigation-types' import { getLinearAgentSkillUpdateTarget } from './linear-agent-skill-update-command' @@ -8,14 +8,14 @@ type AgentSkillNavInstallStatusInput = { name: string installed: boolean loading: boolean - inventory: SkillFreshnessInventory | null + plan: BundledSkillPlan | null } export function getAgentSkillNavInstallStatus({ name, installed, loading, - inventory + plan }: AgentSkillNavInstallStatusInput): SettingsNavInstallStatus { if (loading) { return 'checking' @@ -23,7 +23,7 @@ export function getAgentSkillNavInstallStatus({ if (!installed) { return 'install' } - return getSkillFreshnessDisplayStatus(inventory, name) + return getSkillFreshnessDisplayStatus(plan, name) } export function getLinearAgentSkillNavInstallStatus( diff --git a/src/renderer/src/lib/skill-freshness-display-status.test.ts b/src/renderer/src/lib/skill-freshness-display-status.test.ts index 1bb6f554f09..48dae48bfa8 100644 --- a/src/renderer/src/lib/skill-freshness-display-status.test.ts +++ b/src/renderer/src/lib/skill-freshness-display-status.test.ts @@ -1,263 +1,77 @@ import { describe, expect, it } from 'vitest' -import type { - SkillFreshnessInstallation, - SkillFreshnessInventory, - SkillFreshnessScanIssueReason, - SkillFreshnessStatus -} from '../../../shared/skill-freshness' +import type { BundledSkillOp } from '../../../shared/bundled-skill-plan' +import { + planEntry, + planOf, + planOp +} from '../components/skills/bundled-skill-plan-fixtures.test-support' import { getSkillFreshnessDisplayStatus, - hasSkillCopyNeedingAttention + hasSkillCopyNeedingAttention, + skillPlanHasDetails } from './skill-freshness-display-status' const SKILL_NAME = 'orca-cli' +const SHARED = `/home/u/.agents/skills/${SKILL_NAME}` +const CLAUDE = `/home/u/.claude/skills/${SKILL_NAME}` -function scanIssue( - reason: SkillFreshnessScanIssueReason -): SkillFreshnessInventory['scanIssues'][number] { - return { - rootId: 'codex-plugin-cache', - sourceLabel: 'Codex plugin cache', - path: '/home/.codex/plugins/cache', - reason, - errorCode: reason === 'io-error' ? 'EACCES' : null - } -} - -function placement(status: SkillFreshnessStatus, index = 0): SkillFreshnessInstallation { - return { - id: `${SKILL_NAME}-${index}`, - name: SKILL_NAME, - rootId: 'home-agents', - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - unresolvedPath: `/home/.agents/skills/${SKILL_NAME}-${index}`, - resolvedPath: `/home/.agents/skills/${SKILL_NAME}-${index}`, - physicalIdentity: `physical-${index}`, - topology: 'canonical-copy', - status, - currentPackageDigest: 'current', - lockSource: 'stablyai/orca', - errorCategory: null - } -} - -function pluginCachePlacement( - status: SkillFreshnessStatus = 'outdated' -): SkillFreshnessInstallation { - return { - ...placement(status, 9), - topology: 'plugin-cache', - unresolvedPath: `/home/.codex/plugins/cache/openai-bundled/${SKILL_NAME}` - } -} - -function repoPlacement(status: SkillFreshnessStatus = 'outdated'): SkillFreshnessInstallation { - return { - ...placement(status, 7), - rootId: 'repo-work', - sourceKind: 'repo', - sourceLabel: 'work', - topology: 'repo-scope', - unresolvedPath: `/home/projects/work/.agents/skills/${SKILL_NAME}`, - resolvedPath: `/home/projects/work/.agents/skills/${SKILL_NAME}` - } -} - -function inventory( - installations: SkillFreshnessInstallation[], - eligibleUpdateNames: string[] = [], - scanIssues: SkillFreshnessInventory['scanIssues'] = [] -): SkillFreshnessInventory { - return { schemaVersion: 1, installations, eligibleUpdateNames, scanIssues, scannedAt: 1 } +function planWith(...ops: BundledSkillOp[]) { + return planOf([planEntry(SKILL_NAME, ops)]) } describe('getSkillFreshnessDisplayStatus', () => { - it('shows update available when the inventory authorizes an update', () => { - expect( - getSkillFreshnessDisplayStatus(inventory([placement('outdated')], [SKILL_NAME]), SKILL_NAME) - ).toBe('update-available') + it('reports presence only without a plan or without a copy of Orca’s', () => { + expect(getSkillFreshnessDisplayStatus(null, SKILL_NAME)).toBe('installed') + expect(getSkillFreshnessDisplayStatus(planOf([]), SKILL_NAME)).toBe('installed') + expect(skillPlanHasDetails(null, SKILL_NAME)).toBe(false) }) - it.each([null, 'acme/agent-skills'])( - 'reports presence only for an update to a copy Orca did not install (%s)', - (lockSource) => { - // Why: such a copy is offered unselected in the dialog, never advertised. - expect( - getSkillFreshnessDisplayStatus( - inventory([{ ...placement('outdated'), lockSource }], [SKILL_NAME]), - SKILL_NAME - ) - ).toBe('installed') - } - ) - - it('shows up to date only when every discovered placement is current', () => { - expect( - getSkillFreshnessDisplayStatus( - inventory([placement('current'), placement('current', 1)]), - SKILL_NAME - ) - ).toBe('up-to-date') + it('is up to date when every location is kept', () => { + const plan = planWith(planOp('keep', SHARED), planOp('keep', CLAUDE)) + expect(getSkillFreshnessDisplayStatus(plan, SKILL_NAME)).toBe('up-to-date') + expect(skillPlanHasDetails(plan, SKILL_NAME)).toBe(false) }) - it.each([ - ['before the inventory loads', null], - ['when the inventory has no matching placement', inventory([])] - ])('reports presence only %s', (_scenario, value) => { - // Why: with nothing scanned there is no drift to claim, and flashing attention - // on every launch before the first scan would train the user to ignore it. - expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed') + it('offers an update when a released copy would be rewritten', () => { + const plan = planWith(planOp('update', SHARED), planOp('keep', CLAUDE)) + expect(getSkillFreshnessDisplayStatus(plan, SKILL_NAME)).toBe('update-available') + expect(hasSkillCopyNeedingAttention(plan, SKILL_NAME)).toBe(false) + expect(skillPlanHasDetails(plan, SKILL_NAME)).toBe(true) }) - it.each([ - [ - 'when a placement the update cannot reach is outdated', - inventory([placement('current'), { ...placement('outdated', 1), topology: 'external-link' }]) - ], - ['when a placement is inaccessible', inventory([placement('inaccessible')])], - ['when an outdated placement is not eligible', inventory([placement('outdated')])] - ])('reports needs attention %s', (_scenario, value) => { - // Why: no eligible update is not proof a copy is fine. Green here would read as - // all-clear over drift the update command cannot reach and the user cannot see. - expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('needs-attention') + it('treats link-only work as up to date but still offers details', () => { + const plan = planWith(planOp('keep', SHARED), planOp('link', CLAUDE)) + expect(getSkillFreshnessDisplayStatus(plan, SKILL_NAME)).toBe('up-to-date') + expect(skillPlanHasDetails(plan, SKILL_NAME)).toBe(true) }) - it.each([ - ['depth-limit'], - ['entry-limit'], - ['candidate-limit'], - ['manifest-limit'], - ['issue-limit'], - // Why: a vendor linking its skills out of the cache is a packaging choice, not a - // fault the user can clear — deleting the link only makes the package manager - // recreate it. Amber here is clean-on-main turned permanently amber. - ['outside-root'] - ] as const)('does not report attention for the %s traversal bound', (reason) => { - // Why: these are Orca's own bounds. A large but healthy plugin cache would - // otherwise pin every skill amber with nothing the user could do about it. - expect( - getSkillFreshnessDisplayStatus( - inventory([placement('current')], [], [scanIssue(reason)]), - SKILL_NAME - ) - ).toBe('up-to-date') - }) - - // Why: the only reason left that is a fact about the user's own disk rather than a - // bound Orca chose, so the only one a person can actually clear. - it('reports needs attention for the io-error scan fault', () => { - expect( - getSkillFreshnessDisplayStatus( - inventory([placement('current')], [], [scanIssue('io-error')]), - SKILL_NAME - ) - ).toBe('needs-attention') - }) - - it('stays up to date beside another ecosystem’s same-name skill', () => { - // Why: the copy belongs to another tool, so no user action exists to clear it. - // Amber here is the badge-with-no-exit reported in #10633. - expect( - getSkillFreshnessDisplayStatus( - inventory([placement('current'), pluginCachePlacement()]), - SKILL_NAME - ) - ).toBe('up-to-date') - expect(hasSkillCopyNeedingAttention(inventory([pluginCachePlacement()]), SKILL_NAME)).toBe( - false + it('flags an edited copy left alone, and an interrupted update to repair', () => { + const edited = planWith( + planOp('keep', SHARED), + planOp('leave', CLAUDE, { reason: 'edited', replaceable: true }) ) + expect(getSkillFreshnessDisplayStatus(edited, SKILL_NAME)).toBe('needs-attention') + expect(hasSkillCopyNeedingAttention(edited, SKILL_NAME)).toBe(true) + + const repair = planWith(planOp('restore', SHARED)) + expect(getSkillFreshnessDisplayStatus(repair, SKILL_NAME)).toBe('needs-attention') }) - it('still reports drift in our own copy when a plugin-managed one sits alongside', () => { - expect( - getSkillFreshnessDisplayStatus( - inventory([placement('inaccessible'), pluginCachePlacement()]), - SKILL_NAME - ) - ).toBe('needs-attention') + it("does not flag someone else's same-named skill", () => { + const plan = planWith( + planOp('keep', SHARED), + planOp('leave', CLAUDE, { reason: 'other', replaceable: true }) + ) + expect(getSkillFreshnessDisplayStatus(plan, SKILL_NAME)).toBe('up-to-date') + expect(hasSkillCopyNeedingAttention(plan, SKILL_NAME)).toBe(false) }) - it('reports presence only for a plugin-cache copy, readable or not', () => { - // Why: Orca never writes there and cannot tell an old release from another vendor's skill. - const value = inventory([pluginCachePlacement('inaccessible')]) - - expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed') - expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false) - }) -}) - -describe('hasSkillCopyNeedingAttention', () => { - it('raises no attention marker over a routine outdated copy the update converges', () => { - // Why: an out-of-date convergent copy is ordinary work for the update command. - // Amber on the review affordance there would cry wolf on every routine update. - expect( - hasSkillCopyNeedingAttention(inventory([placement('outdated')], [SKILL_NAME]), SKILL_NAME) - ).toBe(false) - }) - - it('ignores a traversal bound but keeps a real scan fault', () => { - expect( - hasSkillCopyNeedingAttention( - inventory([placement('current')], [], [scanIssue('entry-limit')]), - SKILL_NAME - ) - ).toBe(false) - expect( - hasSkillCopyNeedingAttention( - inventory([placement('current')], [], [scanIssue('io-error')]), - SKILL_NAME - ) - ).toBe(true) - }) - - // The exact shape of the rc.4 report: a pristine global install alongside a drifted - // copy inside a work directory. Orca only updates global skills, so the project copy - // must not turn the badge amber over drift it has no way to fix. - it('reports up to date for a current global copy beside a drifted project copy', () => { - const value = inventory([placement('current'), repoPlacement('outdated')]) - - expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('up-to-date') - expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false) - }) - - // Why by scope and not by status: an outdated or unreadable project copy is equally - // far outside the global updater's reach. - it.each<[SkillFreshnessStatus]>([['outdated'], ['inaccessible']])( - 'ignores a %s project copy when judging global freshness', - (status) => { - const value = inventory([placement('current'), repoPlacement(status)]) - - expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('up-to-date') - } - ) - - // A project copy is not evidence the skill is installed globally, so it reports - // presence rather than a freshness claim about a copy Orca does not manage. - it('reports presence only when every copy is project-owned', () => { - expect(getSkillFreshnessDisplayStatus(inventory([repoPlacement()]), SKILL_NAME)).toBe( - 'installed' - ) - }) - - // Why: an unreadable plugin path could hide a copy of anything, but a skill Orca - // never found anywhere is not the one to blame for it — that reads as a problem - // with a skill the user has not installed. - it('does not blame a skill with no placement for a fault elsewhere in the cache', () => { - const value = inventory([], [], [scanIssue('io-error')]) - - expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed') - expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false) - }) - - // Why: a project copy is not the presence that makes the cache fault this skill's - // problem either. The two helpers have to read the same placements, or the badge calls - // a repo-only skill merely installed while the review affordance flags it amber. - it('does not treat a project copy as the presence that owns a cache fault', () => { - const value = inventory([repoPlacement()], [], [scanIssue('io-error')]) - - expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed') - expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false) + it('reads an unknown op kind from a newer host as needing attention', () => { + const plan = planWith({ + ...planOp('keep', SHARED), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Simulates a newer host's op kind this build does not know. + kind: 'teleport' as BundledSkillOp['kind'] + }) + expect(getSkillFreshnessDisplayStatus(plan, SKILL_NAME)).toBe('needs-attention') }) }) diff --git a/src/renderer/src/lib/skill-freshness-display-status.ts b/src/renderer/src/lib/skill-freshness-display-status.ts index 3364c6eea1c..58df730bfee 100644 --- a/src/renderer/src/lib/skill-freshness-display-status.ts +++ b/src/renderer/src/lib/skill-freshness-display-status.ts @@ -1,10 +1,8 @@ +import type { BundledSkillPlan } from '../../../shared/bundled-skill-plan' import { - isOrcaSkillLockSource, - isSkillCopyNeedingAttention, - isSkillScanIssueNeedingAttention, - skillPlacementParticipatesInGlobalFreshness, - type SkillFreshnessInventory -} from '../../../shared/skill-freshness' + bundledSkillPlanState, + type BundledSkillPlanState +} from '../../../shared/bundled-skill-summary' export type SkillFreshnessDisplayStatus = | 'installed' @@ -12,72 +10,41 @@ export type SkillFreshnessDisplayStatus = | 'update-available' | 'needs-attention' -export function getSkillFreshnessDisplayStatus( - inventory: SkillFreshnessInventory | null, - skillName: string -): SkillFreshnessDisplayStatus { - if (inventory?.eligibleUpdateNames.includes(skillName)) { - // Why: a same-named copy Orca did not install is offered unselected in the dialog, - // but never advertised; "up to date" would be false, so report presence only. - return inventory.installations.some( - (installation) => - installation.name === skillName && isOrcaSkillLockSource(installation.lockSource) - ) - ? 'update-available' - : 'installed' - } - let hasPlacement = false - let hasBlockedCopy = false - for (const installation of inventory?.installations ?? []) { - if (installation.name !== skillName) { - continue - } - // Why: a project's or plugin's own copy is outside the global updater's reach, so it - // can neither stand in as evidence this skill is installed globally nor make the badge - // amber over drift Orca has no way to fix. - if (!skillPlacementParticipatesInGlobalFreshness(installation)) { - continue - } - hasPlacement = true - if (installation.status !== 'current') { - hasBlockedCopy = true - } - } - // Why: with no scan yet (or nothing found) the only honest answer is presence. - // Reporting attention here would flash amber on every launch before the first scan. - if (!hasPlacement) { - return 'installed' - } - // Why: an unreadable plugin path could hide a copy of any known skill, so it stays - // fail-closed — but only for skills Orca actually found somewhere. Flagging a skill - // that isn't installed at all blames it for a fault in someone else's plugin. - if (inventory?.scanIssues.some(isSkillScanIssueNeedingAttention)) { - return 'needs-attention' - } - // Why: no eligible update is not proof a copy is fine — it can equally mean a copy - // is out of date somewhere the update command cannot reach. Saying "Installed" there - // reads as all-clear and hides real drift, so that case gets its own attention state. - return hasBlockedCopy ? 'needs-attention' : 'up-to-date' +function planState(plan: BundledSkillPlan | null, skillName: string): BundledSkillPlanState | null { + const entry = plan?.skills.find((skill) => skill.name === skillName) + return entry ? bundledSkillPlanState(entry) : null } -/** - * Whether a copy needs the user's own hands — it is not current, and running the update - * would not resolve it. This is what marks the review affordance as carrying a problem - * rather than a routine update, so the badge can stay a badge and the dialog explains. - */ +export function getSkillFreshnessDisplayStatus( + plan: BundledSkillPlan | null, + skillName: string +): SkillFreshnessDisplayStatus { + switch (planState(plan, skillName)) { + // Why: no plan yet, or no copy of Orca's here; the only honest answer is presence. + case null: + return 'installed' + case 'update': + return 'update-available' + case 'repair': + case 'attention': + return 'needs-attention' + // Why: linking a newly detected agent is "Install for ", not a stale skill. + case 'install': + case 'current': + return 'up-to-date' + } +} + +/** Whether the Update skills dialog has anything to show or do for this skill. */ +export function skillPlanHasDetails(plan: BundledSkillPlan | null, skillName: string): boolean { + const state = planState(plan, skillName) + return state !== null && state !== 'current' +} + +/** A copy the update leaves for the user, or an interrupted update to repair. */ export function hasSkillCopyNeedingAttention( - inventory: SkillFreshnessInventory | null, + plan: BundledSkillPlan | null, skillName: string ): boolean { - // Why: the same participation filter the status above applies, or an unreadable plugin - // folder flags a repo-only skill the status calls merely installed. - const placements = (inventory?.installations ?? []).filter( - (installation) => - installation.name === skillName && skillPlacementParticipatesInGlobalFreshness(installation) - ) - return ( - (placements.length > 0 && - Boolean(inventory?.scanIssues.some(isSkillScanIssueNeedingAttention))) || - placements.some(isSkillCopyNeedingAttention) - ) + return getSkillFreshnessDisplayStatus(plan, skillName) === 'needs-attention' } diff --git a/src/renderer/src/web/preload-api/web-host-capability-api.ts b/src/renderer/src/web/preload-api/web-host-capability-api.ts index 8a736717c01..9b7b32de4f0 100644 --- a/src/renderer/src/web/preload-api/web-host-capability-api.ts +++ b/src/renderer/src/web/preload-api/web-host-capability-api.ts @@ -7,7 +7,6 @@ import type { ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../../../../shared/computer-use-permissions-types' -import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' import type { SkillDiscoveryResult } from '../../../../shared/skills' import type { SkillDeletePlan, SkillDeleteResult } from '../../../../shared/skill-delete-contract' import { SKILL_DELETE_CAPABILITY } from '../../../../shared/skill-install-capability' @@ -149,21 +148,10 @@ export function createSkillsApi(): NonNullable['skills']> { return { discover: (target) => callRuntimeResult('skills.discover', target, 15_000), - // Why: browser clients have no local skill homes; remote-host freshness stays off until its update rail covers it. - freshnessInventory: (): Promise => - Promise.resolve({ - schemaVersion: 1, - installations: [], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: Date.now() - }), - // Why: with no local skill homes there is nothing to update, so the run rail - // reports a permanently idle state rather than spawning anything. - startUpdateRun: () => Promise.resolve({ started: false as const, reason: 'invalid-names' }), - cancelUpdateRun: () => Promise.resolve(), - acknowledgeUpdateRun: () => Promise.resolve(), - getUpdateRun: () => Promise.resolve({ state: 'idle' as const }), + // Why: browser clients have no local skill homes; remote hosts plan their own (not here). + bundledPlan: () => Promise.reject(new Error('Updating Orca skills requires the desktop app.')), + bundledExecute: () => + Promise.reject(new Error('Updating Orca skills requires the desktop app.')), prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), cancelShare: () => Promise.resolve(), @@ -202,7 +190,6 @@ export function createSkillsApi(): NonNullable['skills']> { Promise.reject(new Error('Skill package management requires the desktop app.')), listWslDistros: () => Promise.resolve([]), onInstallProgress: () => () => {}, - onShareProgress: () => () => {}, - onUpdateRun: () => () => {} + onShareProgress: () => () => {} } } diff --git a/src/shared/agent-feature-install-commands.ts b/src/shared/agent-feature-install-commands.ts index d2c8c52b48c..144b07133e4 100644 --- a/src/shared/agent-feature-install-commands.ts +++ b/src/shared/agent-feature-install-commands.ts @@ -6,11 +6,6 @@ export const ORCA_SKILLS_REPOSITORY_URL = 'https://github.com/stablyai/orca' // `npx skills` can reuse a stale cached copy or another binary (#18687). export const SKILLS_CLI_VERSION = '1.7.2' -// Why: for a commit ref the skills CLI tries `clone --branch ` first and falls back to -// fetching the commit only when git's error is in English, so a translated git fails every -// install (#28914, vercel-labs/skills#2505). Spread over the env of every spawned run. -export const SKILLS_CLI_ENV_OVERRIDES = { LC_ALL: 'C' } as const - export const ORCA_CLI_SKILL_NAME = 'orca-cli' export const COMPUTER_USE_SKILL_NAME = 'computer-use' export const ORCHESTRATION_SKILL_NAME = 'orchestration' diff --git a/src/shared/bundled-skill-locations.test.ts b/src/shared/bundled-skill-locations.test.ts new file mode 100644 index 00000000000..84f0c6b2cd2 --- /dev/null +++ b/src/shared/bundled-skill-locations.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { BundledSkillOp } from './bundled-skill-plan' +import { bundledSkillLocation, collapseHomePath } from './bundled-skill-locations' + +function op(extra: Partial): BundledSkillOp { + return { + kind: 'keep', + path: '/home/u/.agents/skills/orca-cli', + role: 'shared', + agents: ['codex'], + observed: { kind: 'absent' }, + cleanup: [], + ...extra + } +} + +describe('collapseHomePath', () => { + it('shows the home as ~ on POSIX and Windows, and only at a separator', () => { + expect(collapseHomePath('/home/u/.claude/skills/x', '/home/u')).toBe('~/.claude/skills/x') + expect(collapseHomePath('/home/user/x', '/home/u')).toBe('/home/user/x') + expect(collapseHomePath('C:\\Users\\Maya\\.claude\\skills\\x', 'c:\\users\\maya')).toBe( + '~\\.claude\\skills\\x' + ) + expect(collapseHomePath('/srv/x', '/home/u')).toBe('/srv/x') + }) +}) + +describe('bundledSkillLocation', () => { + it('names the shared folder and agent folders, and tells a kept link from a kept copy', () => { + expect(bundledSkillLocation(op({ kind: 'update' }), '/home/u')).toMatchObject({ + displayPath: '~/.agents/skills/orca-cli', + place: { kind: 'shared' }, + state: 'update' + }) + expect( + bundledSkillLocation( + op({ + role: 'agent', + agents: ['claude'], + path: '/home/u/.claude/skills/orca-cli', + observed: { kind: 'link', target: '../../.agents/skills/orca-cli' } + }), + '/home/u' + ) + ).toMatchObject({ place: { kind: 'agents', names: ['Claude Code'] }, state: 'keep-link' }) + expect( + bundledSkillLocation( + op({ + role: 'agent', + agents: ['claude'], + kind: 'leave', + reason: 'edited', + replaceable: true + }), + '/home/u' + ) + ).toMatchObject({ state: 'leave', reason: 'edited', replaceable: true }) + }) +}) diff --git a/src/shared/bundled-skill-locations.ts b/src/shared/bundled-skill-locations.ts new file mode 100644 index 00000000000..5cfbe292a12 --- /dev/null +++ b/src/shared/bundled-skill-locations.ts @@ -0,0 +1,78 @@ +import type { BundledSkillLeaveReason, BundledSkillOp } from './bundled-skill-plan' +import { isBundledSkillLeaveReason } from './bundled-skill-plan' +import { SKILL_INSTALL_PROVIDERS } from './skill-install-providers' + +/** What Update does, or did nothing, at one location. */ +export type BundledSkillLocationState = + | 'update' + | 'repair' + | 'install' + | 'link' + | 'replace' + | 'keep' + | 'keep-link' + | 'leave' + | 'unknown' + +export type BundledSkillLocation = { + path: string + /** `path` with the planned host's home shown as `~`. */ + displayPath: string + /** `shared` is the `.agents/skills` folder; otherwise the agents that read this folder. */ + place: { kind: 'shared' } | { kind: 'agents'; names: string[] } + state: BundledSkillLocationState + reason: BundledSkillLeaveReason | null + /** Whether Replace / `--force` may back it up and put Orca's copy in its place. */ + replaceable: boolean +} + +const OP_STATE: Readonly> = { + update: 'update', + restore: 'repair', + write: 'install', + copy: 'install', + link: 'link', + replace: 'replace', + leave: 'leave' +} + +/** Windows homes compare case-insensitively and may use either separator. */ +export function collapseHomePath(path: string, homeDirectory: string): string { + const windows = /^[a-z]:[\\/]|^\\\\/iu.test(homeDirectory) + const fold = (value: string) => (windows ? value.replace(/\//gu, '\\').toLowerCase() : value) + const home = homeDirectory.replace(/[\\/]+$/u, '') + const head = fold(path.slice(0, home.length)) + const separator = path.charAt(home.length) + if (home && head === fold(home) && (separator === '/' || separator === '\\')) { + return `~${path.slice(home.length)}` + } + return path +} + +function agentNames(agents: readonly string[]): string[] { + return agents.flatMap((agent) => { + const name = SKILL_INSTALL_PROVIDERS.find((provider) => provider.id === agent)?.displayName + return name ? [name] : [] + }) +} + +export function bundledSkillLocation( + op: BundledSkillOp, + homeDirectory: string +): BundledSkillLocation { + const state = + op.kind === 'keep' + ? op.observed.kind === 'link' + ? 'keep-link' + : 'keep' + : (OP_STATE[op.kind] ?? 'unknown') + return { + path: op.path, + displayPath: collapseHomePath(op.path, homeDirectory), + place: + op.role === 'shared' ? { kind: 'shared' } : { kind: 'agents', names: agentNames(op.agents) }, + state, + reason: op.reason && isBundledSkillLeaveReason(op.reason) ? op.reason : null, + replaceable: (op.kind === 'leave' && op.replaceable === true) || op.kind === 'replace' + } +} diff --git a/src/shared/bundled-skill-plan.ts b/src/shared/bundled-skill-plan.ts index 240988c7329..516389ee63f 100644 --- a/src/shared/bundled-skill-plan.ts +++ b/src/shared/bundled-skill-plan.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import type { SkillInstallProviderId } from './skill-install-providers' // Frozen per capability version: a client renders a value it does not know as "Needs attention". @@ -94,6 +95,8 @@ export type BundledSkillPlanEntry = { export type BundledSkillPlan = { scope: 'global' | 'workspace' + /** The planned host's home, so surfaces can show `~` paths; not part of the digest. */ + homeDirectory: string skills: readonly BundledSkillPlanEntry[] /** Canonical digest of `skills`; execute applies only a plan that still hashes to it. */ planDigest: string @@ -122,3 +125,17 @@ export type BundledSkillInstallResult = { scope: 'global' | 'workspace' skills: readonly BundledSkillOutcome[] } + +const BundledSkillForceSchema = z.array(z.string().min(1).max(4096)).max(256) + +/** The local app's request: this machine's global homes, Orca's installed skills only. */ +export const BundledSkillPlanRequestSchema = z.strictObject({ + force: BundledSkillForceSchema.optional() +}) +export type BundledSkillPlanRequest = z.infer + +export const BundledSkillExecuteRequestSchema = z.strictObject({ + force: BundledSkillForceSchema.optional(), + planDigest: z.string().regex(/^[0-9a-f]{64}$/u) +}) +export type BundledSkillExecuteRequest = z.infer diff --git a/src/shared/bundled-skill-summary.test.ts b/src/shared/bundled-skill-summary.test.ts index 420ebbe7559..cef7b6b49cb 100644 --- a/src/shared/bundled-skill-summary.test.ts +++ b/src/shared/bundled-skill-summary.test.ts @@ -9,7 +9,9 @@ import { } from './bundled-skill-plan' import { BUNDLED_SKILL_LEAVE_REASON_TEXT, + bundledSkillInstallAgents, bundledSkillNudgeKey, + bundledSkillPlanState, bundledSkillOutcomeText, summarizeBundledSkillOutcomes, summarizeBundledSkillPlan @@ -25,6 +27,7 @@ function plan( ): BundledSkillPlan { return { scope, + homeDirectory: '/home/u', planDigest: 'digest', skills: ops.map((skillOps, index) => ({ name: `s${index}`, @@ -76,6 +79,14 @@ describe('summarizeBundledSkillPlan', () => { expect(summarizeBundledSkillPlan(plan([[op('keep')]])).hasWork).toBe(false) }) + it("treats someone else's same-named skill as theirs, not as attention", () => { + const other: BundledSkillOp = { ...op('leave'), reason: 'other', replaceable: true } + const edited: BundledSkillOp = { ...op('leave'), reason: 'edited', replaceable: true } + expect(bundledSkillPlanState(plan([[op('keep'), other]]).skills[0])).toBe('current') + expect(bundledSkillPlanState(plan([[op('keep'), edited]]).skills[0])).toBe('attention') + expect(bundledSkillPlanState(plan([[op('update'), other]]).skills[0])).toBe('update') + }) + it('has wording for every leave reason', () => { for (const reason of BUNDLED_SKILL_LEAVE_REASONS) { expect(BUNDLED_SKILL_LEAVE_REASON_TEXT[reason]).toMatch(/\S/u) @@ -113,11 +124,11 @@ describe('summarizeBundledSkillOutcomes', () => { }) expect(summary).toMatchObject({ ok: true, exitCode: 0 }) expect(summary.skills.map((skill) => skill.line)).toEqual([ - "Updated · 1 left alone: You've changed this copy", - "No change · 1 left alone: You've changed this copy" + 'Updated · 1 skipped: Skill edited outside Orca', + 'No change · 1 skipped: Skill edited outside Orca' ]) expect(summary.skills[0].leftAlone).toEqual([ - { path: '/leave', reason: "You've changed this copy" } + { path: '/leave', reason: 'Skill edited outside Orca', code: 'edited' } ]) for (const status of ['changed', 'failed'] as const) { expect( @@ -134,6 +145,61 @@ describe('summarizeBundledSkillOutcomes', () => { }).exitCode ).toBe(1) }) + it('counts skipped skills once while retaining every skipped folder and reason', () => { + const summary = summarizeBundledSkillOutcomes({ + scope: 'global', + skills: [ + { + name: 'orca-cli', + status: 'ran', + ops: [ + { path: '/shared/orca-cli', kind: 'update', status: 'done' }, + { path: '/claude/orca-cli', kind: 'update', status: 'done' } + ] + }, + { + name: 'orchestration', + status: 'ran', + ops: [{ path: '/shared/orchestration', kind: 'update', status: 'done' }] + }, + { + name: 'computer-use', + status: 'ran', + ops: [ + { path: '/shared', kind: 'leave', status: 'done', reason: 'edited' }, + { path: '/claude', kind: 'leave', status: 'done', reason: 'shared-not-current' }, + { path: '/cursor', kind: 'leave', status: 'done', reason: 'shared-not-current' } + ] + } + ] + }) + + expect(summary).toMatchObject({ ok: true, changedCount: 2, leftAloneCount: 1 }) + expect(summary.skills[2].leftAlone.map((left) => left.path)).toEqual([ + '/shared', + '/claude', + '/cursor' + ]) + expect(summary.skills[2].line).toBe( + "No change · 1 skipped: Skill edited outside Orca; The shared copy isn't Orca's" + ) + }) +}) + +describe('bundledSkillInstallAgents', () => { + it('lists the agents newly given the skill, once each and sorted', () => { + const forAgents = ( + kind: BundledSkillOpKind, + agents: BundledSkillOp['agents'] + ): BundledSkillOp => ({ ...op(kind, `/${kind}/${agents.join(',')}`), agents }) + const entries = plan([ + [forAgents('write', ['codex']), forAgents('link', ['claude'])], + [forAgents('copy', ['cursor', 'claude']), forAgents('update', ['gemini'])], + [forAgents('keep', ['droid']), forAgents('leave', ['grok'])] + ]).skills + expect(bundledSkillInstallAgents(entries)).toEqual(['claude', 'codex', 'cursor']) + expect(bundledSkillInstallAgents([])).toEqual([]) + }) }) describe('bundledSkillNudgeKey', () => { diff --git a/src/shared/bundled-skill-summary.ts b/src/shared/bundled-skill-summary.ts index 3ab0c27ef08..ad7fc69965b 100644 --- a/src/shared/bundled-skill-summary.ts +++ b/src/shared/bundled-skill-summary.ts @@ -3,11 +3,14 @@ import { isBundledSkillOpKind, type BundledSkillInstallResult, type BundledSkillLeaveReason, + type BundledSkillOp, type BundledSkillOpKind, type BundledSkillOutcome, type BundledSkillPlan, type BundledSkillPlanEntry } from './bundled-skill-plan' +import type { SkillInstallProviderId } from './skill-install-providers' +import { bundledSkillLocation, type BundledSkillLocation } from './bundled-skill-locations' /** Ordered by precedence: a skill shows its highest state. */ export type BundledSkillPlanState = 'current' | 'attention' | 'install' | 'update' | 'repair' @@ -34,21 +37,38 @@ const OP_PLAN_STATE: Readonly> } export const BUNDLED_SKILL_LEAVE_REASON_TEXT: Readonly> = { - edited: "You've changed this copy", - other: 'A different skill already uses this name', + edited: 'Skill edited outside Orca', + other: 'A different skill uses this name', unwritable: "Orca can't write to this folder", - unreadable: "Orca can't read all of this copy", - busy: 'Another skill install is still finishing here', - 'shared-not-current': 'The shared copy this folder uses is not Orca\u2019s' + unreadable: "Orca can't read every file", + busy: 'Another install is still running', + 'shared-not-current': "The shared copy isn't Orca's" } -function opPlanState(kind: string): BundledSkillPlanState { - return isBundledSkillOpKind(kind) ? OP_PLAN_STATE[kind] : 'attention' +/** What Update does at one location, for plain-text surfaces; the app translates its own. */ +export const BUNDLED_SKILL_PLAN_OP_TEXT: Readonly< + Record, string> +> = { + keep: 'No change', + write: 'Install', + copy: 'Install', + update: 'Update', + link: 'Link', + replace: 'Replace', + restore: 'Repair' +} + +function opPlanState(op: Pick): BundledSkillPlanState { + // Why: someone else's same-named skill is theirs; it is listed, never flagged. + if (op.kind === 'leave' && op.reason === 'other') { + return 'current' + } + return isBundledSkillOpKind(op.kind) ? OP_PLAN_STATE[op.kind] : 'attention' } export function bundledSkillPlanState(entry: BundledSkillPlanEntry): BundledSkillPlanState { return entry.ops.reduce((highest, op) => { - const state = opPlanState(op.kind) + const state = opPlanState(op) return PLAN_STATE_ORDER.indexOf(state) > PLAN_STATE_ORDER.indexOf(highest) ? state : highest }, 'current') } @@ -58,14 +78,28 @@ export type BundledSkillPlanSummary = { badge: 'update' | 'repair' | null /** Whether running the plan would change anything. */ hasWork: boolean - skills: { name: string; state: BundledSkillPlanState }[] + skills: BundledSkillPlanSkillSummary[] +} + +export type BundledSkillPlanSkillSummary = { + name: string + state: BundledSkillPlanState + /** One per planned location, labelled by agent; the dialog's rows and location table. */ + locations: BundledSkillLocation[] + /** Every location is current, so the skill collapses into one line. */ + upToDate: boolean } export function summarizeBundledSkillPlan(plan: BundledSkillPlan): BundledSkillPlanSummary { - const skills = plan.skills.map((entry) => ({ - name: entry.name, - state: bundledSkillPlanState(entry) - })) + const skills = plan.skills.map((entry): BundledSkillPlanSkillSummary => { + const state = bundledSkillPlanState(entry) + return { + name: entry.name, + state, + locations: entry.ops.map((op) => bundledSkillLocation(op, plan.homeDirectory)), + upToDate: state === 'current' && entry.ops.every((op) => op.kind === 'keep') + } + }) const states = new Set(skills.map((skill) => skill.state)) const global = plan.scope === 'global' return { @@ -80,6 +114,18 @@ export function summarizeBundledSkillPlan(plan: BundledSkillPlan): BundledSkillP } } +const INSTALLS_FOR_AGENT = new Set(['write', 'link', 'copy']) + +/** Agents a plan would newly give the skill: the "Install for " work. */ +export function bundledSkillInstallAgents( + entries: readonly BundledSkillPlanEntry[] +): SkillInstallProviderId[] { + const agents = entries.flatMap((entry) => + entry.ops.flatMap((op) => (INSTALLS_FOR_AGENT.has(op.kind) ? op.agents : [])) + ) + return [...new Set(agents)].sort() +} + /** Changes whenever the skills the badge counts, or the bytes they would get, change. */ export function bundledSkillNudgeKey(hostId: string, plan: BundledSkillPlan): string | null { const counted = plan.skills @@ -135,21 +181,26 @@ export function bundledSkillOutcomeText(outcome: BundledSkillOutcome): BundledSk }, 'No change') } -export type BundledSkillLeftAlone = { path: string; reason: string } +export type BundledSkillLeftAlone = { + path: string + reason: string + /** For translated surfaces; null for a reason this build does not know. */ + code: BundledSkillLeaveReason | null +} export type BundledSkillOutcomeSummary = { name: string text: BundledSkillOutcomeText /** Printed as warnings; never part of the exit code. */ leftAlone: BundledSkillLeftAlone[] - /** e.g. "Updated - 1 left alone: You've changed this copy". */ + /** e.g. "Updated - 1 skipped: Skill edited outside Orca". */ line: string } function leaveReasonText(reason: string | undefined): string { return reason && isBundledSkillLeaveReason(reason) ? BUNDLED_SKILL_LEAVE_REASON_TEXT[reason] - : 'Left as it is' + : 'This copy was kept as it is' } export function summarizeBundledSkillOutcome( @@ -158,13 +209,17 @@ export function summarizeBundledSkillOutcome( const text = bundledSkillOutcomeText(outcome) const leftAlone = outcome.ops .filter((op) => op.kind === 'leave') - .map((op) => ({ path: op.path, reason: leaveReasonText(op.reason) })) + .map((op) => ({ + path: op.path, + reason: leaveReasonText(op.reason), + code: op.reason && isBundledSkillLeaveReason(op.reason) ? op.reason : null + })) const reasons = [...new Set(leftAlone.map((left) => left.reason))].join('; ') return { name: outcome.name, text, leftAlone, - line: leftAlone.length > 0 ? `${text} \u00B7 ${leftAlone.length} left alone: ${reasons}` : text + line: leftAlone.length > 0 ? `${text} \u00B7 1 skipped: ${reasons}` : text } } @@ -172,6 +227,9 @@ export type BundledSkillResultSummary = { ok: boolean /** The CLI's exit code: 1 only when an op failed or found the disk changed. */ exitCode: 0 | 1 + /** Skills the run changed: "Updated N skills · M skipped". */ + changedCount: number + leftAloneCount: number skills: BundledSkillOutcomeSummary[] } @@ -179,5 +237,13 @@ export function summarizeBundledSkillOutcomes( result: BundledSkillInstallResult ): BundledSkillResultSummary { const ok = !result.skills.some(hasProblem) - return { ok, exitCode: ok ? 0 : 1, skills: result.skills.map(summarizeBundledSkillOutcome) } + const skills = result.skills.map(summarizeBundledSkillOutcome) + return { + ok, + exitCode: ok ? 0 : 1, + changedCount: skills.filter((skill) => ['Updated', 'Installed', 'Linked'].includes(skill.text)) + .length, + leftAloneCount: skills.filter((skill) => skill.leftAlone.length > 0).length, + skills + } } diff --git a/src/shared/file-name-sort.ts b/src/shared/file-name-sort.ts index 0d2de903fd1..7b2ca1bb848 100644 --- a/src/shared/file-name-sort.ts +++ b/src/shared/file-name-sort.ts @@ -3,8 +3,7 @@ // bare localeCompare — accepted for the feature; don't "optimize" the hoist away. // numeric: true orders "99 - a" before "100 - b", matching Finder/Explorer. // Locale pinned to 'en' so local main, renderer, relay, and remote runtime hosts -// all produce one order regardless of each process's LANG (precedent: -// skill-freshness pins 'en' for canonical ordering). +// all produce one order regardless of each process's LANG. export const fileNameCollator = new Intl.Collator('en', { numeric: true }) export function compareFileNames(a: string, b: string): number { diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts deleted file mode 100644 index 3463036f8c4..00000000000 --- a/src/shared/skill-freshness.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { SkillProvider, SkillSourceKind } from './skills' - -export type SkillBundleFileIdentity = { - path: string - size: number - executable: boolean - classification: 'text' | 'binary' - exactSha256: string - textNormalizedSha256: string | null - identitySha256: string -} - -export type SkillCurrentBundleEntry = { - name: string - sourcePath: string - packageDigest: string - files: SkillBundleFileIdentity[] -} - -// Why: schema 3 dropped the release revision and git tree identity with the -// snapshot registry; freshness compares against this build's bytes only. -export type SkillBundleManifest = { - schemaVersion: 3 - skills: SkillCurrentBundleEntry[] -} - -export type SkillFreshnessStatus = 'current' | 'outdated' | 'inaccessible' - -export type SkillInstallationTopology = - | 'canonical-copy' - | 'provider-alias' - | 'independent-copy' - | 'external-link' - | 'broken-link' - | 'read-only' - | 'repo-scope' - | 'plugin-cache' - -// Why: eligibility and the explanation copy must agree on which placements the -// pinned install rail rewrites; a drifted copy would blame a phantom sibling. -export const SUPPORTED_GLOBAL_SKILL_TOPOLOGIES: ReadonlySet = new Set([ - 'canonical-copy', - 'provider-alias' -]) - -export type SkillFreshnessInstallation = { - id: string - name: string - rootId: string - providers: SkillProvider[] - sourceKind: SkillSourceKind - sourceLabel: string - unresolvedPath: string - resolvedPath: string | null - physicalIdentity: string | null - topology: SkillInstallationTopology - status: SkillFreshnessStatus - currentPackageDigest: string - /** The skills.sh global lock's `source` for this name; null when it has no entry. */ - lockSource: string | null - errorCategory: string | null -} - -const ORCA_SKILL_LOCK_SOURCE = 'stablyai/orca' - -/** Whether the skills.sh lock says this name was installed from Orca's repository. */ -export function isOrcaSkillLockSource(source: string | null): boolean { - if (!source) { - return false - } - const normalized = source - .trim() - .toLowerCase() - .replace(/^https:\/\/github\.com\//, '') - .replace(/\/+$/, '') - .replace(/\.git$/, '') - return normalized === ORCA_SKILL_LOCK_SOURCE -} - -// Why: Orca's updater only ever passes --global, so a project's or a plugin's own copy is -// not something Orca can act on. Without a list of past releases, a differing plugin copy -// is indistinguishable from another vendor's same-named skill, so neither scope can be -// reported as the user's drift. -export function skillPlacementParticipatesInGlobalFreshness( - installation: SkillFreshnessInstallation -): boolean { - return installation.topology !== 'repo-scope' && installation.topology !== 'plugin-cache' -} - -/** - * Whether a copy is wrong in a way running the update would not resolve. - * - * Shared so the badge and the review dialog cannot disagree about what counts: the - * badge points at the dialog for the explanation, so a copy that turns the badge - * amber must also produce a row there. An out-of-date copy the command rewrites is - * ordinary work, not a problem. - */ -export function isSkillCopyNeedingAttention(installation: SkillFreshnessInstallation): boolean { - return ( - skillPlacementParticipatesInGlobalFreshness(installation) && - installation.status !== 'current' && - !( - SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) && - installation.status === 'outdated' - ) - ) -} - -export type SkillFreshnessScanIssueReason = - | 'depth-limit' - | 'entry-limit' - | 'candidate-limit' - | 'manifest-limit' - | 'outside-root' - | 'io-error' - | 'issue-limit' - -export type SkillFreshnessScanIssue = { - rootId: string - sourceLabel: string - path: string - reason: SkillFreshnessScanIssueReason - errorCode: string | null -} - -// Why: a real read failure is a fact about the user's disk and stays actionable. Orca's -// own traversal bounds are not — reporting them as attention turns an ordinary large -// plugin cache into a permanent amber pill on every skill. -// -// 'outside-root' is deliberately NOT here. A plugin that links its skills out of the -// cache (a content-addressed store, a dev-linked package) is a packaging choice by the -// vendor, not a fault the user can clear: deleting the link only makes their package -// manager recreate it. Flagging it turned an install that is clean on main into amber on -// every installed skill. It is still listed in Details, like the other bounds. -const SKILL_SCAN_ATTENTION_REASONS = new Set(['io-error']) - -export function isSkillScanAttentionReason(reason: SkillFreshnessScanIssueReason): boolean { - return SKILL_SCAN_ATTENTION_REASONS.has(reason) -} - -export function isSkillScanIssueNeedingAttention(issue: SkillFreshnessScanIssue): boolean { - return isSkillScanAttentionReason(issue.reason) -} - -// Why: these are the bounds that end the walk rather than skip one folder. They are -// still not the user's to act on, so they raise no pill — but a scan that stopped -// early cannot be reported as proof every copy is up to date. -const SKILL_SCAN_TRUNCATING_REASONS = new Set([ - 'entry-limit', - 'candidate-limit' -]) - -export function isTruncatingSkillScanReason(reason: SkillFreshnessScanIssueReason): boolean { - return SKILL_SCAN_TRUNCATING_REASONS.has(reason) -} - -export function isSkillScanIssueTruncatingScan(issue: SkillFreshnessScanIssue): boolean { - return isTruncatingSkillScanReason(issue.reason) -} - -export type SkillFreshnessInventory = { - schemaVersion: 1 - installations: SkillFreshnessInstallation[] - /** Outdated names the pinned install can safely rewrite, whoever installed them. */ - eligibleUpdateNames: string[] - scanIssues: SkillFreshnessScanIssue[] - scannedAt: number -} - -export function canonicalizeSkillUpdateNames(names: readonly string[]): string[] | null { - const canonicalNames = [...new Set(names)].sort((left, right) => left.localeCompare(right, 'en')) - // Why: names reach a shell in the copied fallback command. Official manifests use - // this restricted package-name grammar so no entry can introduce shell syntax. - if (canonicalNames.some((name) => !/^[a-z0-9][a-z0-9._-]*$/.test(name))) { - return null - } - return canonicalNames.length > 0 ? canonicalNames : null -} - -// Why: `skills add` has no --json, so the run reports one indeterminate phase. -// Per-skill outcomes come from re-scanning the inventory after exit, never from -// parsing stdout. -export type SkillUpdateRun = - | { state: 'idle' } - // `stopping` covers the window between Stop and the process tree actually - // dying — the run is still `running` (that is what blocks a second writer), - // but the Stop affordance has already been spent. - | { state: 'running'; names: string[]; startedAt: number; output: string; stopping?: boolean } - | { state: 'success'; names: string[]; finishedAt: number; output: string } - | { - state: 'error' - names: string[] - finishedAt: number - output: string - message: string - /** Names still outdated after the run — the re-scan is the source of truth. */ - failedNames: string[] - } - -export type SkillUpdateStartResult = - | { started: true } - | { started: false; reason: 'already-running' | 'invalid-names' | 'unsafe-command-path' } diff --git a/src/shared/skills-cli-agent-detection.ts b/src/shared/skills-cli-agent-detection.ts deleted file mode 100644 index 63436416a81..00000000000 --- a/src/shared/skills-cli-agent-detection.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' -import { SKILLS_CLI_UNIVERSAL_AGENT_KEY, toSkillsCliAgentKeys } from './skills-cli-agent-keys' -import { - getTuiAgentDetectionProbeCommands, - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - resolveDetectedTuiAgentIds -} from './tui-agent-detection-commands' - -/** Agents Orca can see on this host, as `skills --agent` keys; empty when it sees none. */ -export function detectSkillsCliAgentKeys(): string[] { - const runtime = process.platform - const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime) - const detected = resolveDetectedTuiAgentIds( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - detectCommandsInInstallDirs(probes), - runtime - ) - return detected.length === 0 ? [] : toSkillsCliAgentKeys(detected) -} - -// Why: an update rewrites a skill the shared folder already holds, so a host with no -// detected agent still has a target, and never the CLI's all-agents default. -export function detectSkillsCliUpdateAgentKeys(): string[] { - const detected = detectSkillsCliAgentKeys() - return detected.length > 0 ? detected : [SKILLS_CLI_UNIVERSAL_AGENT_KEY] -} diff --git a/src/shared/skills-cli-agent-keys.test.ts b/src/shared/skills-cli-agent-keys.test.ts index 1d2a2b32768..a97a5f00ca9 100644 --- a/src/shared/skills-cli-agent-keys.test.ts +++ b/src/shared/skills-cli-agent-keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isUsableSkillsCliAgentKey, toSkillsCliAgentKeys } from './skills-cli-agent-keys' +import { isUsableSkillsCliAgentKey } from './skills-cli-agent-keys' describe('skills CLI agent keys', () => { it('rejects values the skills CLI would drop, and allows the explicit wildcard', () => { @@ -10,15 +10,4 @@ describe('skills CLI agent keys', () => { expect(isUsableSkillsCliAgentKey(good), good).toBe(true) } }) - - it('always includes the shared directory and drops unmappable agents', () => { - expect(toSkillsCliAgentKeys(['claude', 'rovo'])).toEqual([ - 'claude-code', - 'rovodev', - 'universal' - ]) - // Why: `omp` has no skills-CLI equivalent, so it must not reach the argv. - expect(toSkillsCliAgentKeys(['omp'])).toEqual(['universal']) - expect(toSkillsCliAgentKeys([])).toEqual(['universal']) - }) }) diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index 2db871a0284..8a17d453599 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -62,12 +62,6 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { jcode: null } satisfies Record -/** - * The shared `.agents/skills` target every universal agent reads. Always included - * so agents Orca cannot map still receive the skill. - */ -export const SKILLS_CLI_UNIVERSAL_AGENT_KEY = 'universal' - /** * Whether a value is shaped like a `skills --agent` key, or its explicit all-agents * wildcard. @@ -81,15 +75,3 @@ export const SKILLS_CLI_UNIVERSAL_AGENT_KEY = 'universal' export function isUsableSkillsCliAgentKey(value: string): boolean { return /^(?:\*|[a-z0-9][a-z0-9.-]*)$/i.test(value) } - -/** Map detected Orca agents onto `skills --agent` keys, plus the universal target. */ -export function toSkillsCliAgentKeys(detectedAgents: readonly TuiAgent[]): string[] { - const keys = new Set([SKILLS_CLI_UNIVERSAL_AGENT_KEY]) - for (const agent of detectedAgents) { - const key = SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT[agent] - if (key) { - keys.add(key) - } - } - return [...keys].sort() -}