From d3dbd9683fa4fb1c85610c81b9f75dd1d005515d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:35:14 -0400 Subject: [PATCH] Update Orca's skills with Orca's own installer instead of npx skills (#31688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Orca's own bundled-skill planner and installer (not wired in yet) One planner decides what installing Orca's bundled skills does to each target folder; execute re-plans under per-path locks and runs only a plan whose digest still matches; a shared summary derives badge, wording and exit code. A copy counts as Orca's untouched one only when its SKILL.md bytes match a version some build shipped (resources/skills/released-digests.json, generated from git history and checked add-only). Nothing calls this yet; the npx path is unchanged. * Report left-alone folders as warnings and keep backups in the home A leave is no longer a failure: the outcome line says what happened plus "N left alone: ", and the exit code is 1 only for a failed or changed op. Backups for every scope go to /.agents/.orca-skill-backups on the executing host, so a project Replace never writes into the repo; moving there falls back to copy, verify, then remove across filesystems. * Update Orca's skills with Orca's own installer instead of npx skills The app's Update skills dialog, badge, nudge and status pills now read the bundled-skill plan and summary, and Update executes the reviewed plan's digest. `orca skills install|update` runs the same planner in-process with --dry-run, --force, --json and per-summary exit codes. Removes both npx spawn sites, the freshness inventory modules and the npx round-trip CI. * Update only where the skill already is; one provider-root rule for app and CLI installedOnly now drops every location that holds nothing (keeping the shared slot only when an existing agent link points at it), so Update never adds a skill for another agent. The app and CLI resolve the Claude skills folder through the same rule in localBundledSkillInput; a test asserts both plan identically. * Compact Update skills dialog: one row per skill, locations named by agent summarizeBundledSkillPlan now returns each skill's locations, labelled by agent (or "Shared folder") with a ~-collapsed path; the dialog shows one row per skill that needs something, collapses up-to-date skills into one line, and lists every location behind "Show all locations". The result reads "Updated N skills · M left alone". Under installedOnly a Replace with no shared copy replaces in place instead of adding one. * fix(skills): keep location paths readable by putting the state under the path * fix(skills): hold the last plan in state, not a ref mutated during render Split the update dialog's rows, headline and footer into components, move the Update label next to the other plan copy, and use z.strictObject for the request schemas. * Escape non-ASCII characters in bundled-skill regex and wording The orcad bundle rejects a literal non-ASCII character in a regex. * fix(cli): escape non-ASCII apostrophes in the skills install help text * test(orcad): check the bundled CLI plans a skill install without npx Drop the deleted roundtrip workflow's group from the closed-PR cancellation fixture. * Make bundled-skill test fixtures link folders the way the product does on Windows Directory links in fixtures are junctions on Windows, which need no Developer Mode; the one file-link fixture is skipped there. * Resolve legacy skill agent keys canonically * Reject stale skill replacements when auxiliary content changes * Keep ordinary bundled skill classification shallow * Leave unreadable skill replacements alone without aborting plans * Pass force-all through shallow planning and describe unreadable copies * Clarify skill update statuses and folder details * Keep selected skill replacements in place with one status * Report completed skill replacements with their backup confirmation * Clarify available skill updates and skipped copies * Keep skill update results in preview order * Show each skill skip reason once * Count skipped skills instead of skipped folders * Make installer input and link tests work on Windows * Bound bundled skill classification reads and parse names linearly * Keep workspace skill roots inside the resolved workspace * Compare workspace skill roots with native path semantics * Restrict bundled skill backups to their owner on POSIX * Keep recovered skills needing attention until they are updated * Count completed skill updates in the status tooltip * Sync the runtime localization catalog --- .github/workflows/ci-closed-pr-caches.yml | 1 - .github/workflows/release-cut.yml | 2 - .github/workflows/skill-update-roundtrip.yml | 111 -- .../build-plugins/plain-node-entry-guard.ts | 1 + config/electron-builder.config.cjs | 1 + .../process-host-direct-import-baseline.txt | 2 - config/scripts/build-orcad.mjs | 2 +- .../ci-closed-pr-cancellation.test.mjs | 1 - config/scripts/ci-shard-timings.json | 10 - .../electron-vite-output-contract.test.ts | 4 + config/scripts/pr-package-test-paths.mjs | 4 +- config/scripts/run-node-server-tests.mjs | 79 +- .../scripts/verify-skill-update-roundtrip.mjs | 180 --- .../verify-skill-update-roundtrip.test.mjs | 17 - .../windows-cmd-shim-spawn-boundary.test.mjs | 2 - config/tsconfig.node.json | 1 - docs/site/content/docs/cli/reference.mdx | 2 +- docs/site/content/docs/cli/skills.mdx | 37 +- src/cli/cli-version.test.ts | 3 +- src/cli/cli-version.ts | 16 +- .../handlers/bundled-skill-install-command.ts | 281 ++++ src/cli/handlers/skills.ts | 258 +--- src/cli/skills-cli-agent-aliases.test.ts | 27 + src/cli/skills-cli-agent-aliases.ts | 34 + src/cli/skills-install.test.ts | 320 ++++ src/cli/skills.test.ts | 699 +-------- src/cli/specs/skills.ts | 74 +- src/main/ipc/skills.test.ts | 74 +- src/main/ipc/skills.ts | 72 +- src/main/preflight/agent-detection.ts | 30 +- .../preflight/installed-agent-detection.ts | 30 + .../runtime-bundled-skill-install.test.ts | 88 ++ .../runtime/runtime-bundled-skill-install.ts | 53 + .../runtime/runtime-skill-command-contract.ts | 8 + .../runtime/runtime-skill-install-commands.ts | 15 + .../skills/bundled-skill-install/cli-entry.ts | 3 + .../bundled-skill-install/cloud-state.ts | 40 + .../bundled-skill-install/layouts.test.ts | 33 +- .../bundled-skill-install/local-host.test.ts | 95 ++ .../bundled-skill-install/local-host.ts | 64 + .../plan-selection.test.ts | 171 +++ src/main/skills/bundled-skill-install/plan.ts | 140 +- .../skills/bundled-skill-install/service.ts | 6 +- .../skill-home.test-support.ts | 2 + .../skills/skill-bundle-artifacts.test.ts | 40 - src/main/skills/skill-bundle-artifacts.ts | 68 - .../skill-freshness-eligibility.test.ts | 154 -- .../skills/skill-freshness-eligibility.ts | 47 - .../skill-freshness-inventory-limits.test.ts | 27 - .../skills/skill-freshness-inventory.test.ts | 545 ------- src/main/skills/skill-freshness-inventory.ts | 169 --- .../skill-freshness-placement-observation.ts | 195 --- .../skills/skill-installation-topology.ts | 167 --- src/main/skills/skill-package-identity.ts | 11 +- .../skills/skill-plugin-cache-scan.test.ts | 895 ------------ src/main/skills/skill-plugin-cache-scan.ts | 310 ---- .../skills/skill-plugin-manifest-roots.ts | 145 -- src/main/skills/skill-update-outcome.test.ts | 88 -- src/main/skills/skill-update-outcome.ts | 33 - src/main/skills/skill-update-run.test.ts | 373 ----- src/main/skills/skill-update-run.ts | 326 ----- src/preload/api/agent-skill-api.ts | 18 +- src/preload/api/skills-bridge.ts | 25 +- ...inalOrchestrationDialog.freshness.test.tsx | 49 +- .../AgentSkillSetupPanel.freshness.test.tsx | 61 +- .../settings/use-settings-navigation-model.ts | 12 +- .../settings/use-settings-store-model.ts | 4 +- .../skills/SkillFreshnessNudge.test.tsx | 180 +-- .../components/skills/SkillFreshnessNudge.tsx | 71 +- .../skills/SkillFreshnessStatusPill.test.tsx | 72 +- .../skills/SkillFreshnessStatusPill.tsx | 27 +- .../SkillFreshnessUpdateDialog.test.tsx | 1283 ++++++----------- .../skills/SkillFreshnessUpdateDialog.tsx | 597 +++----- .../components/skills/SkillUpdateRow.test.tsx | 288 +++- .../src/components/skills/SkillUpdateRow.tsx | 321 ++--- .../src/components/skills/SkillUpdateRows.tsx | 152 ++ ...undled-skill-plan-fixtures.test-support.ts | 61 + .../skills/skill-freshness-grouping.test.ts | 270 ---- .../skills/skill-freshness-grouping.ts | 128 -- .../skills/skill-freshness-scan-issues.tsx | 74 - .../skill-freshness-skipped-reason.test.ts | 50 - .../skills/skill-freshness-skipped-reason.ts | 79 - .../skill-freshness-summary-headline.tsx | 194 +-- .../skills/skill-location-chip-copy.ts | 70 - .../components/skills/skill-plan-op-copy.ts | 189 +++ .../skills/skill-update-run-store.ts | 206 ++- .../skills/use-reviewed-skill-plan.ts | 56 + .../skills/use-skill-update-selection.ts | 44 - .../SkillUpdateStatusSegment.test.tsx | 164 ++- .../status-bar/SkillUpdateStatusSegment.tsx | 54 +- .../src/hooks/useSkillFreshness.test.tsx | 193 +-- src/renderer/src/hooks/useSkillFreshness.ts | 84 +- .../src/i18n/en-runtime-required.json | 4 + src/renderer/src/i18n/locales/en.json | 116 +- src/renderer/src/i18n/locales/es.json | 116 +- src/renderer/src/i18n/locales/fr.json | 116 +- src/renderer/src/i18n/locales/ja.json | 116 +- src/renderer/src/i18n/locales/ko.json | 116 +- src/renderer/src/i18n/locales/pt.json | 116 +- src/renderer/src/i18n/locales/zh.json | 116 +- .../agent-skill-nav-install-status.test.ts | 57 +- .../src/lib/agent-skill-nav-install-status.ts | 8 +- .../skill-freshness-display-status.test.ts | 290 +--- .../src/lib/skill-freshness-display-status.ts | 105 +- .../preload-api/web-host-capability-api.ts | 23 +- src/shared/agent-feature-install-commands.ts | 5 - src/shared/bundled-skill-locations.test.ts | 59 + src/shared/bundled-skill-locations.ts | 78 + src/shared/bundled-skill-plan.ts | 17 + src/shared/bundled-skill-summary.test.ts | 72 +- src/shared/bundled-skill-summary.ts | 104 +- src/shared/file-name-sort.ts | 3 +- src/shared/skill-freshness.ts | 202 --- src/shared/skills-cli-agent-detection.ts | 26 - src/shared/skills-cli-agent-keys.test.ts | 13 +- src/shared/skills-cli-agent-keys.ts | 18 - 116 files changed, 4406 insertions(+), 9252 deletions(-) delete mode 100644 .github/workflows/skill-update-roundtrip.yml delete mode 100644 config/scripts/verify-skill-update-roundtrip.mjs delete mode 100644 config/scripts/verify-skill-update-roundtrip.test.mjs create mode 100644 src/cli/handlers/bundled-skill-install-command.ts create mode 100644 src/cli/skills-cli-agent-aliases.test.ts create mode 100644 src/cli/skills-cli-agent-aliases.ts create mode 100644 src/cli/skills-install.test.ts create mode 100644 src/main/preflight/installed-agent-detection.ts create mode 100644 src/main/runtime/runtime-bundled-skill-install.test.ts create mode 100644 src/main/runtime/runtime-bundled-skill-install.ts create mode 100644 src/main/skills/bundled-skill-install/cli-entry.ts create mode 100644 src/main/skills/bundled-skill-install/cloud-state.ts create mode 100644 src/main/skills/bundled-skill-install/local-host.test.ts create mode 100644 src/main/skills/bundled-skill-install/local-host.ts create mode 100644 src/main/skills/bundled-skill-install/plan-selection.test.ts delete mode 100644 src/main/skills/skill-bundle-artifacts.test.ts delete mode 100644 src/main/skills/skill-bundle-artifacts.ts delete mode 100644 src/main/skills/skill-freshness-eligibility.test.ts delete mode 100644 src/main/skills/skill-freshness-eligibility.ts delete mode 100644 src/main/skills/skill-freshness-inventory-limits.test.ts delete mode 100644 src/main/skills/skill-freshness-inventory.test.ts delete mode 100644 src/main/skills/skill-freshness-inventory.ts delete mode 100644 src/main/skills/skill-freshness-placement-observation.ts delete mode 100644 src/main/skills/skill-installation-topology.ts delete mode 100644 src/main/skills/skill-plugin-cache-scan.test.ts delete mode 100644 src/main/skills/skill-plugin-cache-scan.ts delete mode 100644 src/main/skills/skill-plugin-manifest-roots.ts delete mode 100644 src/main/skills/skill-update-outcome.test.ts delete mode 100644 src/main/skills/skill-update-outcome.ts delete mode 100644 src/main/skills/skill-update-run.test.ts delete mode 100644 src/main/skills/skill-update-run.ts create mode 100644 src/renderer/src/components/skills/SkillUpdateRows.tsx create mode 100644 src/renderer/src/components/skills/bundled-skill-plan-fixtures.test-support.ts delete mode 100644 src/renderer/src/components/skills/skill-freshness-grouping.test.ts delete mode 100644 src/renderer/src/components/skills/skill-freshness-grouping.ts delete mode 100644 src/renderer/src/components/skills/skill-freshness-scan-issues.tsx delete mode 100644 src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts delete mode 100644 src/renderer/src/components/skills/skill-freshness-skipped-reason.ts delete mode 100644 src/renderer/src/components/skills/skill-location-chip-copy.ts create mode 100644 src/renderer/src/components/skills/skill-plan-op-copy.ts create mode 100644 src/renderer/src/components/skills/use-reviewed-skill-plan.ts delete mode 100644 src/renderer/src/components/skills/use-skill-update-selection.ts create mode 100644 src/shared/bundled-skill-locations.test.ts create mode 100644 src/shared/bundled-skill-locations.ts delete mode 100644 src/shared/skill-freshness.ts delete mode 100644 src/shared/skills-cli-agent-detection.ts 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() -}