From ccd6b40981e19edd46e88faa11a4e13f261241fb Mon Sep 17 00:00:00 2001
From: Jinwoo-H
Date: Thu, 13 Aug 2026 21:36:03 -0400
Subject: [PATCH] feat(skills): complete private bundle sharing
---
...-skill-sharing-implementation-checklist.md | 61 ++-
docs/agent-skill-sharing-installation-plan.md | 61 ++-
.../runtime-auth-service.test.ts | 3 +
.../claude-accounts/runtime-auth-service.ts | 4 +-
src/main/claude-accounts/service.ts | 4 +
src/main/index.ts | 8 +-
.../skill-cloud-install-ipc-schemas.test.ts | 54 +++
.../ipc/skill-cloud-install-ipc-schemas.ts | 5 +
src/main/ipc/skills.ts | 7 +-
.../orca-runtime-skill-recovery.test.ts | 32 ++
src/main/runtime/orca-runtime.ts | 75 +++-
src/main/runtime/rpc/methods/skills.test.ts | 1 +
src/main/runtime/rpc/methods/skills.ts | 7 +-
src/main/skills/discovery.test.ts | 11 +-
src/main/skills/discovery.ts | 30 +-
src/main/skills/skill-bundle-extraction.ts | 6 +-
.../skill-bundle-install-request-service.ts | 22 +-
.../skill-bundle-install-service.test.ts | 117 +++++
.../skills/skill-bundle-install-service.ts | 8 +-
.../skills/skill-bundle-ssh-relay-service.ts | 13 +-
.../skill-candidate-concurrency.test.ts | 21 +
src/main/skills/skill-cloud-deadline.ts | 31 ++
.../skills/skill-cloud-direct-upload.test.ts | 105 +++++
src/main/skills/skill-cloud-direct-upload.ts | 57 ++-
.../skills/skill-cloud-grant-installation.ts | 10 +
src/main/skills/skill-cloud-request.ts | 43 +-
src/main/skills/skill-cloud-service.test.ts | 161 ++++++-
src/main/skills/skill-cloud-service.ts | 54 ++-
src/main/skills/skill-discovery-sources.ts | 81 +++-
src/main/skills/skill-discovery-target.ts | 11 +-
src/main/skills/skill-discovery-wsl.ts | 5 +-
...ill-install-discovery-verification.test.ts | 25 ++
.../skill-install-discovery-verification.ts | 22 +-
src/main/skills/skill-install-filesystem.ts | 1 +
...skill-install-historical-provider-roots.ts | 16 +
.../skill-install-journal-transition.ts | 6 +
.../skill-install-management-service.ts | 30 +-
.../skills/skill-install-provenance.test.ts | 2 +
src/main/skills/skill-install-provenance.ts | 5 +
.../skills/skill-install-request-service.ts | 22 +-
src/main/skills/skill-install-service.test.ts | 95 +++-
src/main/skills/skill-install-service.ts | 134 ++----
.../skill-install-transaction-result.ts | 18 +
src/main/skills/skill-install-transaction.ts | 36 +-
.../skills/skill-operation-observability.ts | 3 +-
.../skill-package-download-availability.ts | 53 +++
.../skills/skill-package-download.test.ts | 79 ++++
src/main/skills/skill-package-download.ts | 200 +++++----
.../skill-placement-artifact-recovery.ts | 129 ++++++
src/main/skills/skill-placement-copy.ts | 41 +-
.../skill-placement-reconciliation.test.ts | 92 ++++
.../skills/skill-placement-reconciliation.ts | 86 ++--
.../skill-placement-recovery-journal.ts | 219 +++++++++
.../skill-placement-transaction-controller.ts | 150 +++++++
.../skill-placement-transaction.test.ts | 328 ++++++++++++++
.../skills/skill-placement-transaction.ts | 235 ++++++++++
.../skill-provider-destinations.test.ts | 167 +++++++
.../skills/skill-provider-destinations.ts | 59 ++-
.../skill-provider-runtime-roots.test.ts | 52 +++
.../skills/skill-provider-runtime-roots.ts | 90 ++++
.../skill-remote-install-service.test.ts | 13 +
.../skills/skill-remote-install-service.ts | 46 +-
src/main/skills/skill-removable-placement.ts | 6 +-
.../skills/skill-remove-transaction.test.ts | 32 +-
src/main/skills/skill-remove-transaction.ts | 19 +-
.../skills/skill-share-preparation-service.ts | 1 +
.../skills/skill-ssh-relay-service.test.ts | 13 +
src/main/skills/skill-ssh-relay-service.ts | 11 +-
.../skill-transaction-startup-recovery.ts | 100 ++++-
.../skill-wsl-install-filesystem.test.ts | 47 +-
.../skills/skill-wsl-install-filesystem.ts | 21 +-
src/relay/skill-install-handler.test.ts | 1 +
src/relay/skill-install-handler.ts | 11 +-
src/renderer/src/App.tsx | 6 +-
src/renderer/src/assets/main.css | 7 +
.../settings/OrcaAccountSettingsPane.tsx | 90 ++--
.../settings/OwnedSkillShareLinks.test.tsx | 63 ---
.../settings/OwnedSkillShareLinks.tsx | 178 --------
.../src/components/settings/Settings.tsx | 2 +-
.../settings/ShareSkillsSettingsPane.test.tsx | 19 +-
.../settings/ShareSkillsSettingsPane.tsx | 43 +-
.../settings/share-skills-settings-search.ts | 2 +-
.../src/components/sidebar/SidebarNav.tsx | 43 +-
.../skills/SkillBundleInstallFlow.tsx | 29 +-
.../skills/SkillBundleInstallReview.tsx | 215 ++-------
.../src/components/skills/SkillCard.tsx | 165 -------
.../SkillCloudManagementActions.test.tsx | 108 -----
.../skills/SkillCloudManagementActions.tsx | 232 ----------
.../skills/SkillDescriptionDisclosure.tsx | 48 ++
.../components/skills/SkillDetailDialog.tsx | 164 +++++++
.../skills/SkillDisclosureTrigger.tsx | 23 +
.../skills/SkillInstallAgentPicker.tsx | 139 ++++++
.../skills/SkillInstallDialog.test.tsx | 161 ++++++-
.../components/skills/SkillInstallDialog.tsx | 90 +++-
.../skills/SkillInstallMachineSelect.tsx | 38 ++
.../SkillInstallManagementDialog.test.tsx | 194 ++++++--
.../skills/SkillInstallManagementDialog.tsx | 176 +++-----
.../skills/SkillInstallManagementStatus.tsx | 31 ++
.../skills/SkillInstallReviewContent.tsx | 112 ++---
.../skills/SkillInstallTargetFields.test.tsx | 6 +-
.../skills/SkillInstallTargetFields.tsx | 14 +
.../skills/SkillManagedInstallDetails.tsx | 174 -------
.../skills/SkillManagedInstallList.tsx | 50 ---
.../skills/SkillManagedInstallRow.tsx | 205 +++++++++
.../skills/SkillPackageChecklist.tsx | 136 ++++++
.../src/components/skills/SkillRow.tsx | 275 ++++++++++++
.../skills/SkillShareDialog.test.tsx | 120 ++++-
.../components/skills/SkillShareDialog.tsx | 240 ++++++----
.../skills/SkillSharePackageSummary.tsx | 108 +++++
.../skills/SkillShareReleaseNotesField.tsx | 68 +++
.../skills/SkillShareReviewContent.tsx | 285 +++++-------
.../skills/SkillShareSelectableCard.tsx | 35 --
.../skills/SkillShareSelectionList.tsx | 41 --
.../skills/SkillSharedLinkRow.test.tsx | 83 ++++
.../components/skills/SkillSharedLinkRow.tsx | 251 +++++++++++
.../skills/SkillSharedLinksView.tsx | 83 ++++
.../components/skills/SkillsFilterToolbar.tsx | 213 +++++++++
.../src/components/skills/SkillsList.tsx | 146 ++++++
.../src/components/skills/SkillsPage.test.tsx | 139 +++++-
.../src/components/skills/SkillsPage.tsx | 424 +++++++-----------
.../components/skills/SkillsPageHeader.tsx | 121 +++++
.../skills/SkillsSelectionHeader.tsx | 101 +++++
.../skills/SkillsSourcesPopover.tsx | 70 +++
.../components/skills/skill-agent-filter.ts | 58 +++
.../skills/skill-bundle-name.test.ts | 61 +++
.../components/skills/skill-bundle-name.ts | 34 ++
.../skills/skill-description-length.test.ts | 21 +
.../skills/skill-description-length.ts | 8 +
.../components/skills/skill-display-labels.ts | 71 ++-
.../skills/skill-install-management-copy.ts | 73 +--
.../skill-install-provider-groups.test.ts | 63 +++
.../skills/skill-install-provider-groups.ts | 85 ++++
.../skills/skill-managed-install-labels.ts | 82 ++++
.../skill-package-checklist-items.test.ts | 111 +++++
.../skills/skill-package-checklist-items.ts | 50 +++
.../skills/skill-package-digest.test.ts | 15 +
.../components/skills/skill-package-digest.ts | 8 +
.../skill-share-preview-summary.test.ts | 60 +++
.../skills/skill-share-preview-summary.ts | 66 +++
.../skills/skill-share-selection.test.ts | 40 ++
...nControls.tsx => skill-share-selection.ts} | 69 +--
.../skills/skill-source-inventory.test.ts | 76 ++++
.../skills/skill-source-inventory.ts | 46 ++
.../components/skills/skills-filter.test.ts | 22 +-
.../src/components/skills/skills-filter.ts | 11 +-
.../components/skills/skills-page-column.ts | 4 +
.../components/skills/skills-page-states.tsx | 122 +++++
.../src/components/skills/skills-page-view.ts | 3 +
.../skills/skills-subtitle-action.ts | 4 +
.../skills/use-owned-skill-shares.ts | 97 ++++
.../skills/use-skill-discovery-host-label.ts | 24 +
.../use-skill-install-detected-agents.ts | 46 ++
.../hooks/useSettingsNavigationMetadata.ts | 8 +-
src/renderer/src/i18n/locales/en.json | 193 +++++++-
src/renderer/src/store/slices/ui.ts | 13 +
src/shared/constants.ts | 1 +
src/shared/protocol-version.ts | 4 +-
src/shared/skill-bundle-install-contract.ts | 2 +
src/shared/skill-cloud-contract.ts | 1 +
src/shared/skill-install-capability.ts | 3 +
src/shared/skill-install-contract.ts | 6 +
src/shared/skill-install-providers.ts | 115 +++++
src/shared/skill-sharing-contract.ts | 4 +
src/shared/types.ts | 2 +
164 files changed, 8685 insertions(+), 2687 deletions(-)
create mode 100644 src/main/ipc/skill-cloud-install-ipc-schemas.test.ts
create mode 100644 src/main/runtime/orca-runtime-skill-recovery.test.ts
create mode 100644 src/main/skills/skill-candidate-concurrency.test.ts
create mode 100644 src/main/skills/skill-cloud-deadline.ts
create mode 100644 src/main/skills/skill-install-historical-provider-roots.ts
create mode 100644 src/main/skills/skill-package-download-availability.ts
create mode 100644 src/main/skills/skill-placement-artifact-recovery.ts
create mode 100644 src/main/skills/skill-placement-recovery-journal.ts
create mode 100644 src/main/skills/skill-placement-transaction-controller.ts
create mode 100644 src/main/skills/skill-placement-transaction.test.ts
create mode 100644 src/main/skills/skill-placement-transaction.ts
create mode 100644 src/main/skills/skill-provider-destinations.test.ts
create mode 100644 src/main/skills/skill-provider-runtime-roots.test.ts
create mode 100644 src/main/skills/skill-provider-runtime-roots.ts
delete mode 100644 src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx
delete mode 100644 src/renderer/src/components/settings/OwnedSkillShareLinks.tsx
delete mode 100644 src/renderer/src/components/skills/SkillCard.tsx
delete mode 100644 src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx
delete mode 100644 src/renderer/src/components/skills/SkillCloudManagementActions.tsx
create mode 100644 src/renderer/src/components/skills/SkillDescriptionDisclosure.tsx
create mode 100644 src/renderer/src/components/skills/SkillDetailDialog.tsx
create mode 100644 src/renderer/src/components/skills/SkillDisclosureTrigger.tsx
create mode 100644 src/renderer/src/components/skills/SkillInstallAgentPicker.tsx
create mode 100644 src/renderer/src/components/skills/SkillInstallMachineSelect.tsx
create mode 100644 src/renderer/src/components/skills/SkillInstallManagementStatus.tsx
delete mode 100644 src/renderer/src/components/skills/SkillManagedInstallDetails.tsx
delete mode 100644 src/renderer/src/components/skills/SkillManagedInstallList.tsx
create mode 100644 src/renderer/src/components/skills/SkillManagedInstallRow.tsx
create mode 100644 src/renderer/src/components/skills/SkillPackageChecklist.tsx
create mode 100644 src/renderer/src/components/skills/SkillRow.tsx
create mode 100644 src/renderer/src/components/skills/SkillSharePackageSummary.tsx
create mode 100644 src/renderer/src/components/skills/SkillShareReleaseNotesField.tsx
delete mode 100644 src/renderer/src/components/skills/SkillShareSelectableCard.tsx
delete mode 100644 src/renderer/src/components/skills/SkillShareSelectionList.tsx
create mode 100644 src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx
create mode 100644 src/renderer/src/components/skills/SkillSharedLinkRow.tsx
create mode 100644 src/renderer/src/components/skills/SkillSharedLinksView.tsx
create mode 100644 src/renderer/src/components/skills/SkillsFilterToolbar.tsx
create mode 100644 src/renderer/src/components/skills/SkillsList.tsx
create mode 100644 src/renderer/src/components/skills/SkillsPageHeader.tsx
create mode 100644 src/renderer/src/components/skills/SkillsSelectionHeader.tsx
create mode 100644 src/renderer/src/components/skills/SkillsSourcesPopover.tsx
create mode 100644 src/renderer/src/components/skills/skill-agent-filter.ts
create mode 100644 src/renderer/src/components/skills/skill-bundle-name.test.ts
create mode 100644 src/renderer/src/components/skills/skill-bundle-name.ts
create mode 100644 src/renderer/src/components/skills/skill-description-length.test.ts
create mode 100644 src/renderer/src/components/skills/skill-description-length.ts
create mode 100644 src/renderer/src/components/skills/skill-install-provider-groups.test.ts
create mode 100644 src/renderer/src/components/skills/skill-install-provider-groups.ts
create mode 100644 src/renderer/src/components/skills/skill-managed-install-labels.ts
create mode 100644 src/renderer/src/components/skills/skill-package-checklist-items.test.ts
create mode 100644 src/renderer/src/components/skills/skill-package-checklist-items.ts
create mode 100644 src/renderer/src/components/skills/skill-package-digest.test.ts
create mode 100644 src/renderer/src/components/skills/skill-package-digest.ts
create mode 100644 src/renderer/src/components/skills/skill-share-preview-summary.test.ts
create mode 100644 src/renderer/src/components/skills/skill-share-preview-summary.ts
create mode 100644 src/renderer/src/components/skills/skill-share-selection.test.ts
rename src/renderer/src/components/skills/{SkillShareSelectionControls.tsx => skill-share-selection.ts} (64%)
create mode 100644 src/renderer/src/components/skills/skill-source-inventory.test.ts
create mode 100644 src/renderer/src/components/skills/skill-source-inventory.ts
create mode 100644 src/renderer/src/components/skills/skills-page-column.ts
create mode 100644 src/renderer/src/components/skills/skills-page-states.tsx
create mode 100644 src/renderer/src/components/skills/skills-page-view.ts
create mode 100644 src/renderer/src/components/skills/skills-subtitle-action.ts
create mode 100644 src/renderer/src/components/skills/use-owned-skill-shares.ts
create mode 100644 src/renderer/src/components/skills/use-skill-discovery-host-label.ts
create mode 100644 src/renderer/src/components/skills/use-skill-install-detected-agents.ts
create mode 100644 src/shared/skill-install-providers.ts
diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md
index 42af7bf2a9f..5a7ff368b35 100644
--- a/docs/agent-skill-sharing-implementation-checklist.md
+++ b/docs/agent-skill-sharing-implementation-checklist.md
@@ -9,13 +9,30 @@ Implementation baselines captured by this checklist update:
- Orca implementation: `skills-share` through `607e31dd04`; no PR.
- Orca Cloud: bundle smoke PR `#329` merged as `eddb144afe`; generation-aware recovery PR `#330`
merged as `8045c85dad`; encrypted physical-host credential PR `#336` merged as `8fce3298ef`;
- kill-switch discovery PR `#342` merged as `c2bef2ff20fb`; production remains untouched.
+ kill-switch discovery PR `#342` merged as `c2bef2ff20fb`.
Windows device-name validation PR `#343` merged as `dbb14a658cbc`; explicit disabled production
bootstrap PR `#352` merged as `2a23f6ace5`; publisher-identity omission PR `#355` merged as
- `b3213bd34d1b224d8a3b11527eceaac883965400`; production remains untouched.
+ `b3213bd34d1b224d8a3b11527eceaac883965400`; production enablement PR `#357`, bounded OIDC smoke
+ PR `#358`, monitor IAM PR `#359`, and monitor release-ownership PRs `#360`/`#361` are merged
+ through `d8605edde72229c5d00d22c8bd6853a13c1a4aaa`.
Validated so far:
+- Current release hardening cancels bundle extraction after partial bytes exist and removes the
+ extraction directory, durable journal, and managed-install state. Four repeated focused runs and
+ the real SIGKILL recovery test passed. Signed GCS uploads use the earlier of signed-policy expiry
+ and a 15-minute cap, including their archive read stream, so slow valid uploads can finish while
+ stalled uploads remain bounded.
+- Local and headless managed-skill operations now wait for the one startup transaction-recovery
+ promise before reading or changing install state. Codex uses its documented global
+ `~/.agents/skills` root, and provider removal derives allowed roots from the registry so aliases
+ remain removable after an agent is no longer detected. The combined focused suite passed 74
+ tests across 12 files, including fake-timer slow-upload and controlled-recovery regressions.
+- The reported desktop install failure was a strict main-process IPC schema rejecting the
+ renderer's additive `providers` selection before bundle verification. All four install schemas
+ now accept the bounded field, and focused schema/provider tests pass. The original production
+ link resolves as a valid one-skill bundle and returns a fresh same-version local download grant;
+ the signed URL and private metadata were not logged or printed during verification.
- The final reliability hardening at `607e31dd04` adds a 60-second Cloud request deadline;
cancellable, journaled bundle extraction; bounded package creation, verification, and preview
fanout; atomic share-preparation admission and abandoned-archive cleanup; process-owned download
@@ -288,12 +305,14 @@ Validated so far:
Rollout gate: staging infrastructure, OIDC owner identity exchange, anonymous bearer lifecycle,
owner management, browser-free desktop bundle lifecycle, privacy-safe logging, published-object
recovery, bounded finalization load, and guarded rollback-capable deployment passed. Production
-remains untouched. Native Windows, real Ubuntu 24.04 WSL filesystems, physical Ubuntu 20.04 and
+infrastructure and the unlisted-link API are live with all four controls enabled after successful
+authenticated candidate and canonical lifecycle smoke. Native Windows, real Ubuntu 24.04 WSL
+filesystems, physical Ubuntu 20.04 and
macOS ARM64 SSH, and paired non-Windows staging passed. On 2026-08-12, the owner accepted the
remaining live WSL-to-staging journey as duplicate evidence after the combined 449-test physical
Windows/WSL run and native-Windows staging lifecycle; this does not treat WSL semantics as
-identical to macOS. Supported Windows SSH and the quarantine lifecycle deletion remain. The shared
-staging data plane is asleep.
+identical to macOS. The user-driven signed-in desktop and real-host production journey, supported
+Windows SSH, and the quarantine lifecycle deletion remain. The shared staging data plane is asleep.
Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md).
@@ -313,7 +332,8 @@ does not mean the surrounding phase is complete.
- [x] The portable archive root conforms to Agent Plugins 1.0.0 for skills-only packages and keeps
Orca integrity metadata in `dev.orca.skill-sharing/manifest.json`.
- [x] One canonical `.agents/skills/` copy is installed for each selected skill;
- provider-specific placements are reconciled only for detected agents.
+ provider-specific placements are reconciled for explicitly selected agents, defaulting to
+ detected agents when no selection is supplied.
- [x] Install, update, rollback, and removal preserve modified or unowned local content unless the
user explicitly approves replacement.
- [x] Package ingestion rejects unsafe archives before any destination mutation.
@@ -1536,15 +1556,32 @@ disconnect boundaries remain separate gates below.
### Production infrastructure and launch
-- [ ] Apply the approved production Terraform plan and verify no unrelated replacement.
-- [ ] Run production database migrations before routing skill traffic.
-- [ ] Deploy the API with all skill flags disabled.
-- [ ] After every first-release gate passes, enable the feature for all accounts in one launch.
+- [x] Apply the approved production Terraform plan and verify no unrelated replacement. The saved
+ skill-only plan added 40 resources with zero updates, replacements, or deletions; the
+ storage-monitor follow-up added one narrow IAM binding with zero changes or deletions. The
+ final hardening used two reviewed targeted phases: 13 additions plus one WIF update, followed
+ by exactly five broad-IAM deletions. A separate targeted apply made nine in-place
+ observability updates. No full plan or unrelated Relay drift was applied.
+- [x] Run production database migrations before routing skill traffic. Disabled-route bootstrap
+ run `31648015091` completed the skill schema migration before any skill route was enabled.
+- [x] Deploy the API with all skill flags disabled. Bootstrap revision `orca-cloud-api-00016-yem`
+ received 100% traffic and returned `404` for the disabled skill route while Artifact and Auth
+ health remained green.
+- [x] After every first-release gate passes, enable the feature for all accounts in one launch.
+ Production run `31650178315` initially enabled upload, download, remote install, and sharing
+ together. Hardening run `31661421728` then passed candidate and canonical authenticated
+ artifact plus skill smoke and promoted `orca-cloud-api-00025-qup` at 100% traffic with digest
+ `sha256:e4f044105ff2345574bdceb36eec1629761428cc2edf9d743939390b857d61e0`.
- [ ] Verify one complete share, local install, remote install, update, rollback, revoke, local
- removal, Cloud deletion, upload expiry, and soft-delete recovery journey.
+ removal, Cloud deletion, upload expiry, and soft-delete recovery journey. Production CI has
+ verified the server-side share/version/grant/revoke/delete lifecycle; the signed-in desktop
+ and real-host production journey remains manual.
- [ ] Verify upload, download, and remote-install kill switches independently.
- [ ] Review error budgets, cost, authorization denials, saturation, orphan counts, and support
- signals.
+ signals. The hardened production revision has zero post-deploy error logs. All eight skill
+ alerts route to the enabled dedicated `Orca skill sharing alerts` channel, and aggregate
+ monitor execution `orca-cloud-skill-storage-monitor-lfd26` passed on the serving API digest
+ with zero overdue objects. Sustained-usage review remains post-launch.
### Launch operations
diff --git a/docs/agent-skill-sharing-installation-plan.md b/docs/agent-skill-sharing-installation-plan.md
index 80b3ede1993..bfda3cab054 100644
--- a/docs/agent-skill-sharing-installation-plan.md
+++ b/docs/agent-skill-sharing-installation-plan.md
@@ -53,22 +53,27 @@ that larger package-manager surface.
## Current execution status
-The Orca implementation is on `skills-share` through `607e31dd04`; no Orca pull request exists.
-Cloud bundle ingestion and bearer-link work merged through `stablyai/orca-cloud#320` as `0579cc1a71`;
-the bundle desktop smoke update merged through `#329` as `eddb144afe`, generation-aware GCS
-recovery merged through `#330` as `8045c85dad`, and the bounded finalization load gate plus cleanup
-hardening merged through `#332`-`#335` as `bb8bf8b9ac`. The encrypted physical-host credential
-handoff merged through `#336` as `8fce3298ef`. The isolated migration coexistence and database
-restore drill merged through `#350` as `c22007384a`.
-The explicit default-off production bootstrap merged through `#352` as `2a23f6ace5`; it performs
-no deployment unless deliberately selected, and all production skill controls remain disabled.
-The authenticated OIDC smoke landed in `#313`; the narrow Auth release-metadata convergence
-follow-up landed in `#314`; the finalization and lifecycle fixes landed in `#317`. The final
-anonymous bearer-link candidate and canonical smoke now pass in staging. Production remains
-untouched. Local, Windows, WSL, paired-runtime, Docker-backed SSH, browser-free staging desktop,
-physical Ubuntu 20.04 SSH, published-object recovery, and bounded finalization-load validation are
-substantially complete; the implementation checklist records the exact evidence and remaining
-physical-host and lifecycle gates.
+The Orca implementation is on `skills-share` through `2a0b7b2acf`; no Orca pull request exists.
+The local working tree also contains the install-IPC compatibility fix and the in-progress Skills
+UI overhaul. Cloud bundle ingestion, bearer links, recovery, load testing, production bootstrap,
+OIDC smoke, and monitor ownership merged through `stablyai/orca-cloud#361`. Final infrastructure
+hardening merged in `#364` as `57c4958979fc32ed4753959cea45edd3a7c8775c`.
+
+Production is live. Two reviewed targeted Terraform phases added the dedicated app deploy identity,
+restricted both GitHub WIF providers to exact workflows, added resource-scoped API/Auth/monitor and
+Relay bindings, then removed five broad legacy bindings. A separate exact plan updated only the
+bearer request-log exclusion and all eight skill alert policies; the alerts use the dedicated
+`Orca skill sharing alerts` email channel. No full production plan was applied because it contains
+unrelated Relay drift.
+
+Production deploy run `31661421728` promoted `orca-cloud-api-00025-qup` at 100% traffic from merged
+`main`, using immutable digest
+`sha256:e4f044105ff2345574bdceb36eec1629761428cc2edf9d743939390b857d61e0`. Candidate and canonical
+authenticated artifact and skill smoke passed, `/health` and `/ready` are green, and the direct
+unlisted landing route returns only generic install copy plus the Orca deep link with `no-store`,
+`no-referrer`, and restrictive CSP headers. Monitor execution
+`orca-cloud-skill-storage-monitor-lfd26` passed on the same digest and emitted only aggregate
+inventory. The implementation checklist records the remaining desktop and physical-host gates.
The final reliability hardening gives every skill Cloud request a default deadline, makes bundle
extraction cancellable and recoverable through the durable extraction journal, bounds package and
@@ -266,7 +271,29 @@ candidate/canonical smoke passed. Its unrelated post-promotion storage-monitor i
MIGs stable and reached at target zero, and the API at minimum scale zero while revision
`00060-qay` retained 100% traffic.
-Production remains untouched.
+Production deployment completed on 2026-08-12. The exact skill-only Terraform apply created the
+private bucket, database, secret, least-privilege identities, request-log exclusion, metrics,
+alerts, and dashboard with 40 additions and zero updates, replacements, or deletions. Disabled-route
+bootstrap run `31648015091` migrated the schema and promoted `orca-cloud-api-00016-yem` while all
+four controls remained false. Cloud PR `#357` enabled all four controls in one launch. Cloud PR
+`#358` replaced the missing long-lived production smoke secret with an exchange restricted to the
+exact production `main` workflow, GitHub environment, dispatch event, repository/owner IDs, and
+audience; issued principals expire in ten minutes and have no refresh credentials. Auth run
+`31649381596` passed candidate and canonical health. API run `31650178315` passed candidate and
+canonical authenticated artifact plus skill lifecycle smoke, promoted
+`orca-cloud-api-00022-maq` to 100% traffic, and completed the aggregate monitor image update. All
+four skill controls are true, the serving revision reported zero deployment-window errors, and
+both API and Auth health are green.
+
+Cloud PR `#359` codified the deploy identity's `roles/iam.serviceAccountUser` grant only on the
+read-only skill storage monitor identity. Its exact production apply added one resource with zero
+changes or deletions, and the repeat targeted plan was zero-diff. Manual execution
+`orca-cloud-skill-storage-monitor-bmm4q` completed successfully. Cloud PRs `#360` and `#361` keep
+Terraform ownership of the monitor shape while assigning its immutable image and Cloud Run client
+metadata to the release workflow; the exact production monitor plan is zero-diff after the live
+image advance. The development Orca instance
+`skill-sharing-manual` now points at `login.onorca.dev` and `cloud-api.onorca.dev`; sign-in and the
+desktop/real-host production lifecycle remain user-driven validation.
A final renderer lifecycle review fenced managed-install inventory by request generation so a
slower previous machine cannot overwrite the newly selected machine's list. It also moved bundle
diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts
index a78bc99ec1c..15ec69062f4 100644
--- a/src/main/claude-accounts/runtime-auth-service.test.ts
+++ b/src/main/claude-accounts/runtime-auth-service.test.ts
@@ -3526,6 +3526,9 @@ describe('ClaudeRuntimeAuthService', () => {
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
+ expect(service.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
+ ubuntuAuthPath
+ )
const preparation = await service.prepareForClaudeLaunch()
expect(preparation).toMatchObject({
diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts
index 58328222528..b655d454ac3 100644
--- a/src/main/claude-accounts/runtime-auth-service.ts
+++ b/src/main/claude-accounts/runtime-auth-service.ts
@@ -151,8 +151,8 @@ export class ClaudeRuntimeAuthService {
})
}
- getRuntimeConfigDir(): string {
- return this.pathResolver.getRuntimePaths().configDir
+ getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
+ return this.getPreparation(target).configDir
}
private initializeLastSyncedState(): void {
diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts
index 3e3a8ec5f47..853ad4a77f0 100644
--- a/src/main/claude-accounts/service.ts
+++ b/src/main/claude-accounts/service.ts
@@ -148,6 +148,10 @@ export class ClaudeAccountService {
return this.cancelPendingClaudeLogin?.() ?? false
}
+ getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
+ return this.runtimeAuth.getRuntimeConfigDir(target)
+ }
+
private serializeMutation(fn: () => Promise): Promise {
const next = this.mutationQueue.then(fn, fn)
this.mutationQueue = next.catch(() => {})
diff --git a/src/main/index.ts b/src/main/index.ts
index 9c7d4950224..0d348d814d8 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -2360,7 +2360,10 @@ void app.whenReady().then(async () => {
packaged: app.isPackaged,
platform: process.platform
})
- void recoverPendingSkillTransactions(join(app.getPath('userData'), 'skill-installs'))
+ const skillTransactionRecovery = recoverPendingSkillTransactions(
+ join(app.getPath('userData'), 'skill-installs')
+ )
+ void skillTransactionRecovery
.then((report) => {
if (report.scanned || report.failures.length || report.truncated) {
console.info('[skills] startup transaction recovery:', {
@@ -2563,7 +2566,8 @@ void app.whenReady().then(async () => {
}),
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {},
- orchestrationEnvironmentTransport
+ orchestrationEnvironmentTransport,
+ skillTransactionRecovery
})
runtime = runtimeService
runtimeService.prepareLegacyWorkerTerminalRecovery()
diff --git a/src/main/ipc/skill-cloud-install-ipc-schemas.test.ts b/src/main/ipc/skill-cloud-install-ipc-schemas.test.ts
new file mode 100644
index 00000000000..bb7671824d5
--- /dev/null
+++ b/src/main/ipc/skill-cloud-install-ipc-schemas.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import {
+ skillCloudBundlePackageVersionInstallSchema,
+ skillCloudBundleShareInstallSchema,
+ skillCloudPackageVersionInstallSchema,
+ skillCloudShareInstallSchema
+} from './skill-cloud-install-ipc-schemas'
+
+const destination = { scope: 'global' as const }
+const providers = ['codex', 'claude']
+
+describe('skill cloud install IPC schemas', () => {
+ it('accepts provider selection for every install entry point', () => {
+ expect(
+ skillCloudShareInstallSchema.parse({ shareId: 'share-1', destination, providers })
+ ).toMatchObject({ providers })
+ expect(
+ skillCloudBundleShareInstallSchema.parse({
+ shareId: 'share-1',
+ selectedSkillIds: ['skill-1'],
+ destination,
+ providers
+ })
+ ).toMatchObject({ providers })
+ expect(
+ skillCloudPackageVersionInstallSchema.parse({
+ packageId: 'package-1',
+ versionId: 'version-1',
+ destination,
+ providers
+ })
+ ).toMatchObject({ providers })
+ expect(
+ skillCloudBundlePackageVersionInstallSchema.parse({
+ packageId: 'package-1',
+ versionId: 'version-1',
+ selectedSkillIds: ['skill-1'],
+ destination,
+ providers
+ })
+ ).toMatchObject({ providers })
+ })
+
+ it('bounds provider selection', () => {
+ expect(() =>
+ skillCloudBundleShareInstallSchema.parse({
+ shareId: 'share-1',
+ selectedSkillIds: ['skill-1'],
+ destination,
+ providers: Array.from({ length: 65 }, (_, index) => `provider-${index}`)
+ })
+ ).toThrow()
+ })
+})
diff --git a/src/main/ipc/skill-cloud-install-ipc-schemas.ts b/src/main/ipc/skill-cloud-install-ipc-schemas.ts
index 503d0ea3839..c0ffc59b9c1 100644
--- a/src/main/ipc/skill-cloud-install-ipc-schemas.ts
+++ b/src/main/ipc/skill-cloud-install-ipc-schemas.ts
@@ -4,10 +4,13 @@ import { SkillInstallDestinationSchema } from '../../shared/skill-install-contra
export const skillCloudInstallEnvironmentIdSchema = z.string().min(1).max(128)
+const skillCloudInstallProvidersSchema = z.array(z.string().min(1).max(64)).max(64)
+
const installDestinationFields = {
operationId: z.string().min(1).max(128).optional(),
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
destination: SkillInstallDestinationSchema,
+ providers: skillCloudInstallProvidersSchema.optional(),
conflictResolution: z
.enum(['replace-unmodified', 'replace-and-discard-local', 'cancel'])
.optional()
@@ -29,6 +32,7 @@ export const skillCloudBundleShareInstallSchema = z
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds,
destination: SkillInstallDestinationSchema,
+ providers: skillCloudInstallProvidersSchema.optional(),
conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional()
})
.strict()
@@ -49,6 +53,7 @@ export const skillCloudBundlePackageVersionInstallSchema = z
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds,
destination: SkillInstallDestinationSchema,
+ providers: skillCloudInstallProvidersSchema.optional(),
conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional()
})
.strict()
diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts
index 2a5c77e3c38..fe7647b605d 100644
--- a/src/main/ipc/skills.ts
+++ b/src/main/ipc/skills.ts
@@ -24,7 +24,12 @@ import { registerSkillCloudIpcHandlers } from './skill-cloud-ipc-handlers'
export function registerSkillsHandlers(store: Store, runtime?: OrcaRuntimeService): void {
const discover = async (target?: SkillDiscoveryTarget): Promise => {
const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined
- return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos())
+ const resolvedTarget = resolveSkillDiscoveryTarget(parsedTarget)
+ return discoverSkillsOnTarget(
+ resolvedTarget,
+ store.getRepos(),
+ await runtime?.resolveSkillDiscoveryProviderRoots(resolvedTarget)
+ )
}
const scanInventory = (): Promise =>
// Why: the update command targets this machine's global homes. WSL and SSH
diff --git a/src/main/runtime/orca-runtime-skill-recovery.test.ts b/src/main/runtime/orca-runtime-skill-recovery.test.ts
new file mode 100644
index 00000000000..3f1f72841ce
--- /dev/null
+++ b/src/main/runtime/orca-runtime-skill-recovery.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from 'vitest'
+import { OrcaRuntimeService } from './orca-runtime'
+
+vi.mock('electron', () => ({
+ BrowserWindow: { fromId: vi.fn(() => null) },
+ webContents: { fromId: vi.fn(() => null) },
+ ipcMain: { on: vi.fn(), removeListener: vi.fn() },
+ app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
+}))
+
+describe('managed skill startup recovery', () => {
+ it('waits for startup recovery before reading local managed installs', async () => {
+ let finishRecovery!: () => void
+ const recovery = new Promise((resolve) => {
+ finishRecovery = resolve
+ })
+ const runtime = new OrcaRuntimeService(null, undefined, {
+ skillTransactionRecovery: recovery
+ })
+ const listing = runtime.listManagedSkillInstalls()
+ let settled = false
+ void listing.finally(() => {
+ settled = true
+ })
+
+ await Promise.resolve()
+ expect(settled).toBe(false)
+
+ finishRecovery()
+ await expect(listing).resolves.toEqual([])
+ })
+})
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 58ba26c55d7..87f8814d907 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -169,9 +169,15 @@ import {
removeSharedSkillInstall
} from '../skills/skill-install-management-service'
import { listManagedSkillInstalls } from '../skills/skill-install-provenance'
-import { getWslHome } from '../wsl'
+import { getWslHome, toLinuxPath } from '../wsl'
import { WslSkillInstallFilesystem } from '../skills/skill-wsl-install-filesystem'
import { nativeSkillInstallFilesystem } from '../skills/skill-install-filesystem'
+import type { SkillProviderRootOverrides } from '../skills/skill-provider-destinations'
+import {
+ resolveEnvironmentSkillProviderRoots,
+ resolveWslGrokSkillProviderRoot,
+ withClaudeSkillProviderRoot
+} from '../skills/skill-provider-runtime-roots'
import type {
SkillUploadBeginRequest,
SkillUploadChunkRequest
@@ -3302,6 +3308,7 @@ export class OrcaRuntimeService {
private artifactService: ArtifactCloudService | null = null
private skillCloudService: SkillCloudService | null = null
private skillUploadSessions: SkillUploadSessionService | null = null
+ private readonly skillTransactionRecovery: Promise
private readonly skillInstallOperations = new Map()
private readonly skillInstallProgress = new Map()
private readonly claudeAgentTeams = new ClaudeAgentTeamsService()
@@ -3355,6 +3362,7 @@ export class OrcaRuntimeService {
getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus
agentSessionClaimSigner?: AgentSessionClaimSigner
orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport
+ skillTransactionRecovery?: Promise
}
) {
this.store = store
@@ -3367,6 +3375,7 @@ export class OrcaRuntimeService {
this.store?.setMobileClientTabSelections?.(state)
})
this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null
+ this.skillTransactionRecovery = deps?.skillTransactionRecovery ?? Promise.resolve()
if (stats) {
this.stats = stats
this.agentDetector = new AgentDetector(stats)
@@ -4740,6 +4749,7 @@ export class OrcaRuntimeService {
onProgress: reportProgress
})
}
+ await this.skillTransactionRecovery
const allowedDownloadOrigins = ['https://storage.googleapis.com']
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
allowedDownloadOrigins.push(
@@ -4756,6 +4766,8 @@ export class OrcaRuntimeService {
resolveStagedUpload: (uploadId, identity) =>
this.requireSkillUploadSessions().take(uploadId, identity),
detectProviders: detectInstalledAgentsWithShellPathHydration,
+ resolveProviderRootOverrides: (destination) =>
+ this.resolveSkillProviderRootOverrides(destination),
signal: controller.signal,
onProgress: reportProgress
})
@@ -4800,6 +4812,7 @@ export class OrcaRuntimeService {
signal
})
}
+ await this.skillTransactionRecovery
const allowedDownloadOrigins = ['https://storage.googleapis.com']
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
allowedDownloadOrigins.push(
@@ -4816,6 +4829,8 @@ export class OrcaRuntimeService {
resolveStagedUpload: (uploadId, identity) =>
this.requireSkillUploadSessions().take(uploadId, identity),
detectProviders: detectInstalledAgentsWithShellPathHydration,
+ resolveProviderRootOverrides: (destination) =>
+ this.resolveSkillProviderRootOverrides(destination),
signal
})
}
@@ -4838,10 +4853,13 @@ export class OrcaRuntimeService {
workspace: sshTarget.workspace
})
}
+ await this.skillTransactionRecovery
return previewSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
- detectProviders: detectInstalledAgentsWithShellPathHydration
+ detectProviders: detectInstalledAgentsWithShellPathHydration,
+ resolveProviderRootOverrides: (destination) =>
+ this.resolveSkillProviderRootOverrides(destination)
})
}
@@ -4881,11 +4899,14 @@ export class OrcaRuntimeService {
}))
})
}
+ await this.skillTransactionRecovery
const runtimeId = this.getStatus().runtimeId
return previewSharedSkillBundleInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
- detectProviders: detectInstalledAgentsWithShellPathHydration
+ detectProviders: detectInstalledAgentsWithShellPathHydration,
+ resolveProviderRootOverrides: (destination) =>
+ this.resolveSkillProviderRootOverrides(destination)
})
}
@@ -4905,10 +4926,13 @@ export class OrcaRuntimeService {
workspace: sshTarget.workspace
})
}
+ await this.skillTransactionRecovery
return removeSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
- detectProviders: detectInstalledAgentsWithShellPathHydration
+ detectProviders: detectInstalledAgentsWithShellPathHydration,
+ resolveProviderRootOverrides: (destination) =>
+ this.resolveSkillProviderRootOverrides(destination)
})
}
@@ -4921,6 +4945,7 @@ export class OrcaRuntimeService {
workspaces: await this.listSkillSshWorkspaces(connectionId)
})
}
+ await this.skillTransactionRecovery
const runtimeId = this.getStatus().runtimeId
const [installs, worktrees] = await Promise.all([
listManagedSkillInstalls(join(app.getPath('userData'), 'skill-installs'), {
@@ -4979,6 +5004,48 @@ export class OrcaRuntimeService {
return Boolean(await this.resolveSkillSshTarget(destination))
}
+ async resolveSkillDiscoveryProviderRoots(target: {
+ kind: 'native-host' | 'wsl'
+ distro?: string
+ }): Promise {
+ const roots = await this.resolveSkillProviderRootOverrides({
+ scope: 'global',
+ homeDirectory: homedir(),
+ ...(target.kind === 'wsl' && target.distro ? { wslDistro: target.distro } : {})
+ })
+ if (target.kind !== 'wsl') {
+ return roots
+ }
+ return Object.fromEntries(
+ Object.entries(roots).map(([provider, root]) => [provider, toLinuxPath(root)])
+ )
+ }
+
+ private async resolveSkillProviderRootOverrides(destination: {
+ scope: 'global' | 'workspace'
+ homeDirectory: string
+ workspaceDirectory?: string
+ wslDistro?: string
+ }): Promise {
+ if (destination.scope !== 'global') {
+ return {}
+ }
+ const wslGrokRoot = destination.wslDistro
+ ? await resolveWslGrokSkillProviderRoot(destination.wslDistro)
+ : null
+ const roots: SkillProviderRootOverrides = destination.wslDistro
+ ? wslGrokRoot
+ ? { grok: wslGrokRoot }
+ : {}
+ : resolveEnvironmentSkillProviderRoots()
+ const claudeConfigDirectory = this.accountServices?.claudeAccounts.getRuntimeConfigDir(
+ destination.wslDistro
+ ? { runtime: 'wsl', wslDistro: destination.wslDistro }
+ : { runtime: 'host' }
+ )
+ return withClaudeSkillProviderRoot(roots, claudeConfigDirectory)
+ }
+
private skillInstallDestinationAuthority(runtimeId: string): SkillInstallDestinationAuthority {
return {
environmentId: runtimeId,
diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts
index a9197663e9f..c37c3f8b41e 100644
--- a/src/main/runtime/rpc/methods/skills.test.ts
+++ b/src/main/runtime/rpc/methods/skills.test.ts
@@ -30,6 +30,7 @@ function makeContext(overrides: {
return {
runtime: {
listRepos: () => [],
+ resolveSkillDiscoveryProviderRoots: async () => ({}),
resolveProjectRuntimeForWorktree:
overrides.resolveProjectRuntimeForWorktree ?? (() => undefined)
}
diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts
index 01cedf0a24a..fd29651f384 100644
--- a/src/main/runtime/rpc/methods/skills.ts
+++ b/src/main/runtime/rpc/methods/skills.ts
@@ -35,7 +35,12 @@ export const SKILL_METHODS: RpcMethod[] = [
...params,
projectRuntime: runtime.resolveProjectRuntimeForWorktree(params.worktreeId)
}
- return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(target), runtime.listRepos())
+ const resolvedTarget = resolveSkillDiscoveryTarget(target)
+ return discoverSkillsOnTarget(
+ resolvedTarget,
+ runtime.listRepos(),
+ await runtime.resolveSkillDiscoveryProviderRoots(resolvedTarget)
+ )
}
}),
defineMethod({
diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts
index cbe33cbad42..092fab884ea 100644
--- a/src/main/skills/discovery.test.ts
+++ b/src/main/skills/discovery.test.ts
@@ -218,7 +218,16 @@ describe('skill discovery', () => {
'/home/test/.omp/agent/skills',
'/home/test/.gemini/skills',
'/home/test/.gemini/antigravity/skills',
- '/home/test/.cursor/skills'
+ '/home/test/.cursor/skills',
+ '/home/test/.factory/skills',
+ '/home/test/.continue/skills',
+ '/home/test/.trae-cn/skills',
+ '/home/test/.augment/skills',
+ '/workspace/current/.factory/skills',
+ '/workspace/current/.continue/skills',
+ '/workspace/current/.trae/skills',
+ '/workspace/current/.grok/skills',
+ '/workspace/current/.augment/skills'
])
)
// Why: these live outside ~/.agents/skills, so they must carry the shared
diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts
index 00edfc8e705..6adb71fa293 100644
--- a/src/main/skills/discovery.ts
+++ b/src/main/skills/discovery.ts
@@ -18,6 +18,8 @@ import {
type SkillScanRoot
} from './skill-discovery-sources'
import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources'
+import { runSkillCandidateTasks } from './skill-candidate-concurrency'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
export { buildSkillDiscoverySources } from './skill-discovery-sources'
@@ -187,8 +189,8 @@ type ScannedSkill = DiscoveredSkill & { canonicalSkillFilePath: string }
async function scanRoot(root: SkillScanRoot): Promise {
const maxDepth = root.sourceKind === 'plugin' ? 9 : 4
const skillFiles = await findSkillFiles(root.path, maxDepth)
- const skills = await Promise.all(
- skillFiles.map(async (skillFilePath): Promise => {
+ const skills = await runSkillCandidateTasks(
+ skillFiles.map((skillFilePath) => async (): Promise => {
// Why: path identity belongs to the scanning host; canonicalizing before
// returning prevents symlinked roots from becoming duplicate picker rows.
const canonicalSkillFilePath = await realpath(skillFilePath).catch(() => skillFilePath)
@@ -225,6 +227,7 @@ export async function discoverSkills(args: {
homeDir?: string
cwd?: string
includeCwd?: boolean
+ providerRootOverrides?: SkillProviderRootOverrides
}): Promise {
const homeDir = args.homeDir ?? homedir()
const roots = [
@@ -236,21 +239,22 @@ export async function discoverSkills(args: {
: [])
]
const sources: SkillDiscoverySource[] = []
- const skillGroups = await Promise.all(
- roots.map(async (root) => {
+ const rootResults = await runSkillCandidateTasks(
+ roots.map((root) => async () => {
const exists = await pathExists(root.path)
- sources.push({
- ...root,
- providers: [...root.providers],
- exists,
- skippedReason: exists ? undefined : 'missing'
- })
- if (!exists) {
- return []
+ return {
+ source: {
+ ...root,
+ providers: [...root.providers],
+ exists,
+ skippedReason: exists ? undefined : 'missing'
+ } satisfies SkillDiscoverySource,
+ skills: exists ? await scanRoot(root) : []
}
- return scanRoot(root)
})
)
+ sources.push(...rootResults.map((result) => result.source))
+ const skillGroups = rootResults.map((result) => result.skills)
const seen = new Map()
for (const skill of skillGroups.flat()) {
// Why: overlapping repo/cwd roots and symlinked provider homes can reach
diff --git a/src/main/skills/skill-bundle-extraction.ts b/src/main/skills/skill-bundle-extraction.ts
index e215b2fa225..2c779693dda 100644
--- a/src/main/skills/skill-bundle-extraction.ts
+++ b/src/main/skills/skill-bundle-extraction.ts
@@ -235,7 +235,11 @@ export async function extractSkillBundleArchive(input: {
throwIfCancelled(input.signal)
return { pluginManifest, manifest, skillsDirectory, ...archiveIdentity }
} catch (error) {
- const failure = error instanceof Error ? error : new Error(String(error))
+ const failure = input.signal?.aborted
+ ? new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE, { cause: error })
+ : error instanceof Error
+ ? error
+ : new Error(String(error))
archive.abort(failure)
await archive.archiveIdentity.catch(() => undefined)
if (destinationCreated) {
diff --git a/src/main/skills/skill-bundle-install-request-service.ts b/src/main/skills/skill-bundle-install-request-service.ts
index 183a9a39ae3..4630a29095e 100644
--- a/src/main/skills/skill-bundle-install-request-service.ts
+++ b/src/main/skills/skill-bundle-install-request-service.ts
@@ -11,8 +11,10 @@ import type { SkillInstallDestinationAuthority } from './skill-install-destinati
import { installSkillBundle } from './skill-bundle-install-service'
import { downloadSkillPackageGrant } from './skill-package-download'
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
+import { selectedOrDetectedSkillProviders } from '../../shared/skill-install-providers'
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
import { startSkillBundleInstallOperation } from './skill-operation-observability'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
type StagedSkillBundle = { archivePath: string; cleanup(): Promise }
@@ -26,6 +28,12 @@ export type SkillBundleInstallRequestDependencies = {
onProgress?: (progress: SkillBundleInstallProgress) => void
fetcher?: typeof fetch
detectProviders: () => Promise
+ resolveProviderRootOverrides?: (destination: {
+ scope: 'global' | 'workspace'
+ homeDirectory: string
+ workspaceDirectory?: string
+ wslDistro?: string
+ }) => Promise | SkillProviderRootOverrides
resolveStagedUpload?: (
uploadId: string,
identity: SkillBundlePackageIdentity
@@ -72,14 +80,19 @@ async function executeParsedSkillBundleInstallRequest(
dependencies.authority
)
ingress = await resolveIngress(request, dependencies)
- const detectedProviders = destination.wslDistro
- ? await detectSkillProvidersInWsl(destination.wslDistro)
- : await dependencies.detectProviders()
+ const detectedProviders = selectedOrDetectedSkillProviders(
+ destination.wslDistro
+ ? await detectSkillProvidersInWsl(destination.wslDistro)
+ : await dependencies.detectProviders(),
+ request.providers
+ )
+ const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination)
const filesystem = destination.wslDistro
? createWslSkillInstallFilesystem({
distro: destination.wslDistro,
homeDirectory: destination.homeDirectory,
- workspaceDirectory: destination.workspaceDirectory
+ workspaceDirectory: destination.workspaceDirectory,
+ providerRootOverrides
})
: undefined
return await installSkillBundle({
@@ -97,6 +110,7 @@ async function executeParsedSkillBundleInstallRequest(
workspaceDirectory: destination.workspaceDirectory,
orcaStateDirectory: dependencies.stateDirectory,
detectedProviders,
+ providerRootOverrides,
destinationIdentity: destination.destinationIdentity,
hostIdentity: dependencies.authority.environmentId,
expectedArchiveSha256: request.package.archiveSha256,
diff --git a/src/main/skills/skill-bundle-install-service.test.ts b/src/main/skills/skill-bundle-install-service.test.ts
index 83ce75e4425..aca545fe759 100644
--- a/src/main/skills/skill-bundle-install-service.test.ts
+++ b/src/main/skills/skill-bundle-install-service.test.ts
@@ -1,3 +1,4 @@
+import { readdirSync, statSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -66,6 +67,77 @@ describe('skill bundle installation', () => {
await expect(readdir(join(root, 'home', '.agents', 'skills'))).resolves.toEqual([])
})
+ it('cancels during bundle extraction and removes its journal and partial bytes', async () => {
+ const root = await temporaryDirectory()
+ const source = await createSkill(join(root, 'sources'), 'alpha-skill')
+ const payloadBytes = 256 * 1024
+ await writeFile(join(source, 'payload.bin'), Buffer.alloc(payloadBytes, 0x61))
+ const bundle = await createSkillBundleArchive({
+ sources: [{ sourceDirectory: source }],
+ archivePath: join(root, 'bundle.tar.gz'),
+ packageId: 'package_mid_extract_cancel',
+ versionId: 'version_mid_extract_cancel',
+ bundleName: 'team-skills'
+ })
+ const destinationRoot = join(root, 'home', '.agents', 'skills')
+ const controller = new AbortController()
+ let observedPartialBytes = false
+ const signal = new Proxy(controller.signal, {
+ get(target, property) {
+ if (property === 'aborted' && !target.aborted) {
+ const extraction = readdirSync(destinationRoot, { withFileTypes: true }).find(
+ (entry) => entry.isDirectory() && entry.name.startsWith('.orca-skill-extract-')
+ )
+ if (extraction) {
+ const size = (() => {
+ try {
+ return statSync(
+ join(destinationRoot, extraction.name, 'skills', 'alpha-skill', 'payload.bin')
+ ).size
+ } catch {
+ return 0
+ }
+ })()
+ if (size > 0 && size < payloadBytes) {
+ observedPartialBytes = true
+ controller.abort()
+ }
+ }
+ }
+ const value = Reflect.get(target, property, target) as unknown
+ return typeof value === 'function' ? value.bind(target) : value
+ }
+ })
+
+ await expect(
+ installSkillBundle({
+ operationId: 'operation_mid_extract_cancel',
+ archivePath: bundle.archivePath,
+ packageId: bundle.manifest.packageId,
+ versionId: bundle.manifest.versionId,
+ bundleDigest: bundle.manifest.bundleDigest,
+ selectedSkillIds: ['alpha-skill'],
+ expectedArchiveSha256: bundle.archiveSha256,
+ scope: 'global',
+ homeDirectory: join(root, 'home'),
+ orcaStateDirectory: join(root, 'state'),
+ detectedProviders: [],
+ destinationIdentity: 'local-global',
+ hostIdentity: 'host_1',
+ signal
+ })
+ ).rejects.toMatchObject({ data: SKILL_INSTALL_CANCELLED_FAILURE })
+
+ expect(observedPartialBytes).toBe(true)
+ await expect(readdir(destinationRoot)).resolves.toEqual([])
+ await expect(
+ readdir(join(root, 'state', 'skill-installs', 'extraction-journals'))
+ ).resolves.toEqual([])
+ await expect(listManagedSkillInstalls(join(root, 'state', 'skill-installs'))).resolves.toEqual(
+ []
+ )
+ })
+
it('installs a selected subset and keeps an unowned conflict local', async () => {
const root = await temporaryDirectory()
const alpha = await createSkill(join(root, 'sources'), 'alpha-skill')
@@ -142,4 +214,49 @@ describe('skill bundle installation', () => {
})
])
})
+
+ it('reports partial when a selected provider placement is unowned', async () => {
+ const root = await temporaryDirectory()
+ const alpha = await createSkill(join(root, 'sources'), 'alpha-skill')
+ const homeDirectory = join(root, 'home')
+ const providerPath = join(homeDirectory, '.claude', 'skills', 'alpha-skill')
+ await mkdir(providerPath, { recursive: true })
+ await writeFile(join(providerPath, 'SKILL.md'), '# Unowned')
+ const bundle = await createSkillBundleArchive({
+ sources: [{ sourceDirectory: alpha }],
+ archivePath: join(root, 'bundle.tar.gz'),
+ packageId: 'package_provider_partial',
+ versionId: 'version_1',
+ bundleName: 'team-skills'
+ })
+
+ const result = await installSkillBundle({
+ operationId: 'operation_provider_partial',
+ archivePath: bundle.archivePath,
+ packageId: bundle.manifest.packageId,
+ versionId: bundle.manifest.versionId,
+ bundleDigest: bundle.manifest.bundleDigest,
+ selectedSkillIds: ['alpha-skill'],
+ expectedArchiveSha256: bundle.archiveSha256,
+ scope: 'global',
+ homeDirectory,
+ orcaStateDirectory: join(root, 'state'),
+ detectedProviders: ['claude'],
+ destinationIdentity: 'local-global',
+ hostIdentity: 'host_1'
+ })
+
+ expect(result).toMatchObject({
+ status: 'partial',
+ skills: [
+ {
+ name: 'alpha-skill',
+ status: 'installed',
+ placements: expect.arrayContaining([
+ expect.objectContaining({ provider: 'claude', status: 'skipped' })
+ ])
+ }
+ ]
+ })
+ })
})
diff --git a/src/main/skills/skill-bundle-install-service.ts b/src/main/skills/skill-bundle-install-service.ts
index e51f419d2c0..b4021d24019 100644
--- a/src/main/skills/skill-bundle-install-service.ts
+++ b/src/main/skills/skill-bundle-install-service.ts
@@ -132,7 +132,13 @@ function bundleStatus(
if (skills.every((skill) => skill.status === 'failed')) {
return 'failed'
}
- return skills.some((skill) => ['kept-local', 'failed', 'cancelled'].includes(skill.status))
+ return skills.some(
+ (skill) =>
+ ['kept-local', 'failed', 'cancelled'].includes(skill.status) ||
+ skill.placements.some(
+ (placement) => placement.status === 'failed' || placement.status === 'skipped'
+ )
+ )
? 'partial'
: 'complete'
}
diff --git a/src/main/skills/skill-bundle-ssh-relay-service.ts b/src/main/skills/skill-bundle-ssh-relay-service.ts
index 644a58bd0b5..0f5dfe04858 100644
--- a/src/main/skills/skill-bundle-ssh-relay-service.ts
+++ b/src/main/skills/skill-bundle-ssh-relay-service.ts
@@ -7,6 +7,7 @@ import {
} from '../../shared/skill-bundle-install-contract'
import {
SKILL_BUNDLE_INSTALL_CAPABILITY,
+ SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_INSTALL_PROGRESS_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
} from '../../shared/skill-install-capability'
@@ -41,6 +42,10 @@ export async function installSkillBundleOnSshHost(input: {
}): Promise {
const client = requireSkillSshRelayClient(input.provider)
const supported = await skillSshRelayCapabilities(client)
+ const request = input.request
+ if (request.providers !== undefined && !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) {
+ throw new Error('skill-bundle-ssh-update-required')
+ }
if (!supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) {
recordSkillCapabilityAbsence({
capability: SKILL_BUNDLE_INSTALL_CAPABILITY,
@@ -54,7 +59,7 @@ export async function installSkillBundleOnSshHost(input: {
read: async () => {
const value = await client(
SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD,
- { operationId: input.request.operationId },
+ { operationId: request.operationId },
{ timeoutMs: 2_000, signal: input.signal }
)
if (value === null) {
@@ -75,14 +80,14 @@ export async function installSkillBundleOnSshHost(input: {
call: () =>
client(
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
- { request: input.request, workspace: input.workspace },
+ { request: request, workspace: input.workspace },
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
)
})
)
} catch (error) {
if (
- input.request.ingress.kind !== 'download-grant' ||
+ request.ingress.kind !== 'download-grant' ||
!shouldUseSkillSshClientTransfer(error, input.requireHttps)
) {
throw error
@@ -106,7 +111,7 @@ export async function installSkillBundleOnSshHost(input: {
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
{
request: {
- ...input.request,
+ ...request,
ingress: { kind: 'staged-upload', uploadId }
},
workspace: input.workspace
diff --git a/src/main/skills/skill-candidate-concurrency.test.ts b/src/main/skills/skill-candidate-concurrency.test.ts
new file mode 100644
index 00000000000..c3fd515a4fa
--- /dev/null
+++ b/src/main/skills/skill-candidate-concurrency.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { runSkillCandidateTasks } from './skill-candidate-concurrency'
+
+describe('runSkillCandidateTasks', () => {
+ it('bounds concurrency and preserves task order', async () => {
+ let active = 0
+ let maxActive = 0
+ const results = await runSkillCandidateTasks(
+ Array.from({ length: 12 }, (_, index) => async () => {
+ active += 1
+ maxActive = Math.max(maxActive, active)
+ await new Promise((resolve) => setTimeout(resolve, 12 - index))
+ active -= 1
+ return index
+ })
+ )
+
+ expect(maxActive).toBe(4)
+ expect(results).toEqual(Array.from({ length: 12 }, (_, index) => index))
+ })
+})
diff --git a/src/main/skills/skill-cloud-deadline.ts b/src/main/skills/skill-cloud-deadline.ts
new file mode 100644
index 00000000000..8fd14ce3d82
--- /dev/null
+++ b/src/main/skills/skill-cloud-deadline.ts
@@ -0,0 +1,31 @@
+const DEFAULT_SKILL_CLOUD_TIMEOUT_MS = 60_000
+
+export function createSkillCloudDeadline(input: {
+ signal?: AbortSignal
+ timeoutMs?: number
+ timeoutMessage: string
+}): {
+ signal: AbortSignal
+ cleanup(): void
+} {
+ const timeoutMs = input.timeoutMs ?? DEFAULT_SKILL_CLOUD_TIMEOUT_MS
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
+ throw new Error(`${input.timeoutMessage}-invalid`)
+ }
+ const controller = new AbortController()
+ const forwardAbort = (): void => controller.abort(input.signal?.reason)
+ if (input.signal?.aborted) {
+ forwardAbort()
+ } else {
+ input.signal?.addEventListener('abort', forwardAbort, { once: true })
+ }
+ const timeout = setTimeout(() => controller.abort(new Error(input.timeoutMessage)), timeoutMs)
+ timeout.unref()
+ return {
+ signal: controller.signal,
+ cleanup: () => {
+ clearTimeout(timeout)
+ input.signal?.removeEventListener('abort', forwardAbort)
+ }
+ }
+}
diff --git a/src/main/skills/skill-cloud-direct-upload.test.ts b/src/main/skills/skill-cloud-direct-upload.test.ts
index 4ee0789c301..f065a05d1b3 100644
--- a/src/main/skills/skill-cloud-direct-upload.test.ts
+++ b/src/main/skills/skill-cloud-direct-upload.test.ts
@@ -7,10 +7,79 @@ import { uploadSkillPackageToSignedPolicy } from './skill-cloud-direct-upload'
const roots: string[] = []
afterEach(async () => {
+ vi.useRealTimers()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('uploadSkillPackageToSignedPolicy', () => {
+ it('allows a slow progressing upload to continue beyond one minute', async () => {
+ vi.useFakeTimers()
+ const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
+ roots.push(root)
+ const archivePath = join(root, 'package.tar.gz')
+ const archive = Buffer.alloc(3 * 64 * 1024, 0x61)
+ await writeFile(archivePath, archive)
+ let markStarted!: () => void
+ const started = new Promise((resolve) => {
+ markStarted = resolve
+ })
+ let chunksSeen = 0
+ const chunkWaiters = new Set<() => void>()
+ const waitForChunk = async (count: number): Promise => {
+ if (chunksSeen >= count) {
+ return
+ }
+ await new Promise((resolve) => {
+ const check = (): void => {
+ if (chunksSeen >= count) {
+ chunkWaiters.delete(check)
+ resolve()
+ }
+ }
+ chunkWaiters.add(check)
+ })
+ }
+ const fetcher = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
+ markStarted()
+ const body = init?.body as unknown as AsyncIterable
+ for await (const _chunk of body) {
+ chunksSeen += 1
+ chunkWaiters.forEach((notify) => notify())
+ await new Promise((resolve) => setTimeout(resolve, 30_000))
+ }
+ return new Response(null, { status: 204 })
+ })
+ const progress = vi.fn()
+ const upload = uploadSkillPackageToSignedPolicy({
+ policy: {
+ url: 'https://storage.googleapis.com/upload',
+ fields: {},
+ expiresAt: new Date(Date.now() + 10 * 60_000).toISOString()
+ },
+ archivePath,
+ expectedBytes: archive.length,
+ fetcher: fetcher as typeof fetch,
+ onProgress: progress
+ })
+
+ await started
+ await waitForChunk(1)
+ await vi.advanceTimersByTimeAsync(30_000)
+ await waitForChunk(2)
+ await vi.advanceTimersByTimeAsync(30_000)
+ await waitForChunk(3)
+ await vi.advanceTimersByTimeAsync(1_000)
+ expect(progress).toHaveBeenCalled()
+ expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
+ await vi.advanceTimersByTimeAsync(29_000)
+ await waitForChunk(4)
+ await vi.advanceTimersByTimeAsync(30_000)
+ await waitForChunk(5)
+ await vi.advanceTimersByTimeAsync(30_000)
+
+ await expect(upload).resolves.toBeUndefined()
+ })
+
it('streams exact bytes with policy fields and bounded progress', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
roots.push(root)
@@ -67,5 +136,41 @@ describe('uploadSkillPackageToSignedPolicy', () => {
expectedBytes: 4
})
).rejects.toThrow('skill-cloud-upload-source-changed')
+ await expect(
+ uploadSkillPackageToSignedPolicy({
+ policy: {
+ url: 'https://storage.test/upload',
+ fields: {},
+ expiresAt: 'not-a-date'
+ },
+ archivePath,
+ expectedBytes: 5
+ })
+ ).rejects.toThrow('skill-cloud-upload-policy-expiry-invalid')
+ })
+
+ it('bounds a stalled signed upload even without a caller-owned signal', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
+ roots.push(root)
+ const archivePath = join(root, 'package.tar.gz')
+ await writeFile(archivePath, 'bytes')
+ const fetcher = vi.fn(
+ async (_url: URL | RequestInfo, init?: RequestInit) =>
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
+ })
+ ) as typeof fetch
+
+ const upload = uploadSkillPackageToSignedPolicy({
+ policy: { url: 'https://storage.googleapis.com/upload', fields: {} },
+ archivePath,
+ expectedBytes: 5,
+ fetcher,
+ timeoutMs: 25
+ })
+ const startedAt = Date.now()
+
+ await expect(upload).rejects.toThrow('skill-cloud-upload-timeout')
+ expect(Date.now() - startedAt).toBeLessThan(1_000)
})
})
diff --git a/src/main/skills/skill-cloud-direct-upload.ts b/src/main/skills/skill-cloud-direct-upload.ts
index d733fb13803..d4ce7d78f67 100644
--- a/src/main/skills/skill-cloud-direct-upload.ts
+++ b/src/main/skills/skill-cloud-direct-upload.ts
@@ -2,10 +2,28 @@ import { randomBytes } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { Readable } from 'node:stream'
+import { createSkillCloudDeadline } from './skill-cloud-deadline'
type SignedPostPolicy = {
url: string
fields: Record
+ expiresAt?: string
+}
+
+const DEFAULT_UPLOAD_TIMEOUT_MS = 15 * 60_000
+
+function resolveUploadTimeoutMs(policy: SignedPostPolicy, timeoutMs?: number): number {
+ if (timeoutMs !== undefined) {
+ return timeoutMs
+ }
+ if (!policy.expiresAt) {
+ return DEFAULT_UPLOAD_TIMEOUT_MS
+ }
+ const expiresAt = Date.parse(policy.expiresAt)
+ if (!Number.isFinite(expiresAt)) {
+ throw new Error('skill-cloud-upload-policy-expiry-invalid')
+ }
+ return Math.max(1, Math.min(DEFAULT_UPLOAD_TIMEOUT_MS, expiresAt - Date.now()))
}
function quoted(value: string): string {
@@ -30,6 +48,7 @@ export async function uploadSkillPackageToSignedPolicy(input: {
signal?: AbortSignal
onProgress?: (bytesSent: number) => void
fetcher?: typeof fetch
+ timeoutMs?: number
}): Promise {
const policyUrl = new URL(input.policy.url)
if (policyUrl.protocol !== 'https:' || policyUrl.username || policyUrl.password) {
@@ -53,13 +72,18 @@ export async function uploadSkillPackageToSignedPolicy(input: {
fileHeader.length +
archive.size +
ending.length
+ const deadline = createSkillCloudDeadline({
+ signal: input.signal,
+ timeoutMs: resolveUploadTimeoutMs(input.policy, input.timeoutMs),
+ timeoutMessage: 'skill-cloud-upload-timeout'
+ })
async function* body() {
for (const field of fields) {
yield field
}
yield fileHeader
let bytesSent = 0
- for await (const chunk of createReadStream(input.archivePath, { signal: input.signal })) {
+ for await (const chunk of createReadStream(input.archivePath, { signal: deadline.signal })) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
bytesSent += bytes.length
if (bytesSent > input.expectedBytes) {
@@ -73,19 +97,22 @@ export async function uploadSkillPackageToSignedPolicy(input: {
}
yield ending
}
- const request: RequestInit & { duplex: 'half' } = {
- method: 'POST',
- headers: {
- 'content-type': `multipart/form-data; boundary=${boundary}`,
- 'content-length': String(contentLength)
- },
- body: Readable.from(body()) as unknown as BodyInit,
- duplex: 'half',
- redirect: 'error',
- signal: input.signal
- }
- const response = await (input.fetcher ?? fetch)(policyUrl, request)
- if (!response.ok && response.status !== 201 && response.status !== 204) {
- throw new Error('skill-cloud-upload-failed')
+ try {
+ const response = await (input.fetcher ?? fetch)(policyUrl, {
+ method: 'POST',
+ headers: {
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
+ 'content-length': String(contentLength)
+ },
+ body: Readable.from(body()) as unknown as BodyInit,
+ duplex: 'half',
+ redirect: 'error',
+ signal: deadline.signal
+ } as RequestInit & { duplex: 'half' })
+ if (!response.ok && response.status !== 201 && response.status !== 204) {
+ throw new Error('skill-cloud-upload-failed')
+ }
+ } finally {
+ deadline.cleanup()
}
}
diff --git a/src/main/skills/skill-cloud-grant-installation.ts b/src/main/skills/skill-cloud-grant-installation.ts
index e7d74218c6e..add69270c5d 100644
--- a/src/main/skills/skill-cloud-grant-installation.ts
+++ b/src/main/skills/skill-cloud-grant-installation.ts
@@ -27,6 +27,7 @@ export type SkillCloudGrantInstallInput = {
operationId: string
environmentId?: string
destination: SkillInstallDestination
+ providers?: string[]
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
}
@@ -35,6 +36,7 @@ export type SkillBundleCloudGrantInstallInput = {
environmentId?: string
selectedSkillIds: string[]
destination: SkillInstallDestination
+ providers?: string[]
conflictDecisions?: SkillBundleInstallRequest['conflictDecisions']
}
@@ -90,6 +92,7 @@ export async function installSkillBundleCloudGrant(
selectedSkillIds: input.selectedSkillIds,
ingress: { kind: 'download-grant', ...grant.grant },
destination: input.destination,
+ ...(input.providers ? { providers: input.providers } : {}),
conflictDecisions: input.conflictDecisions ?? []
}
try {
@@ -127,6 +130,9 @@ export async function installSkillBundleCloudGrant(
}
} catch (error) {
const failure = skillInstallFailureFromError(error)
+ if (failure?.category === 'compatibility') {
+ return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
+ }
if (!failure) {
throw error
}
@@ -155,6 +161,7 @@ export async function installSkillCloudGrant(
expiresAt: grant.grant.expiresAt
},
destination: input.destination,
+ ...(input.providers ? { providers: input.providers } : {}),
conflictResolution: input.conflictResolution
}
try {
@@ -188,6 +195,9 @@ export async function installSkillCloudGrant(
}
} catch (error) {
const failure = skillInstallFailureFromError(error)
+ if (failure?.category === 'compatibility') {
+ return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
+ }
if (!failure) {
throw error
}
diff --git a/src/main/skills/skill-cloud-request.ts b/src/main/skills/skill-cloud-request.ts
index 184c2c58ee2..3798e29ebc4 100644
--- a/src/main/skills/skill-cloud-request.ts
+++ b/src/main/skills/skill-cloud-request.ts
@@ -1,6 +1,5 @@
import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config'
-
-const DEFAULT_SKILL_CLOUD_REQUEST_TIMEOUT_MS = 60_000
+import { createSkillCloudDeadline } from './skill-cloud-deadline'
export class SkillCloudRequestError extends Error {
constructor(
@@ -12,37 +11,6 @@ export class SkillCloudRequestError extends Error {
}
}
-function requestSignal(
- input: AbortSignal | undefined,
- timeoutMs: number
-): {
- signal: AbortSignal
- cleanup(): void
-} {
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
- throw new Error('skill-cloud-request-timeout-invalid')
- }
- const controller = new AbortController()
- const forwardAbort = (): void => controller.abort(input?.reason)
- if (input?.aborted) {
- forwardAbort()
- } else {
- input?.addEventListener('abort', forwardAbort, { once: true })
- }
- const timeout = setTimeout(
- () => controller.abort(new Error('skill-cloud-request-timeout')),
- timeoutMs
- )
- timeout.unref()
- return {
- signal: controller.signal,
- cleanup: () => {
- clearTimeout(timeout)
- input?.removeEventListener('abort', forwardAbort)
- }
- }
-}
-
export async function skillCloudRequest(input: {
apiUrl?: string
authToken?: string
@@ -59,10 +27,11 @@ export async function skillCloudRequest(input: {
if (url.origin !== apiUrl || !url.pathname.startsWith('/v1/')) {
throw new Error('skill-cloud-request-path-invalid')
}
- const deadline = requestSignal(
- input.signal,
- input.timeoutMs ?? DEFAULT_SKILL_CLOUD_REQUEST_TIMEOUT_MS
- )
+ const deadline = createSkillCloudDeadline({
+ signal: input.signal,
+ timeoutMs: input.timeoutMs,
+ timeoutMessage: 'skill-cloud-request-timeout'
+ })
try {
const response = await (input.fetcher ?? fetch)(url, {
method: input.method ?? 'GET',
diff --git a/src/main/skills/skill-cloud-service.test.ts b/src/main/skills/skill-cloud-service.test.ts
index 549c435ebe4..43d575a34ed 100644
--- a/src/main/skills/skill-cloud-service.test.ts
+++ b/src/main/skills/skill-cloud-service.test.ts
@@ -1,7 +1,8 @@
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SkillCloudVersion } from '../../shared/skill-cloud-contract'
import { SkillCloudService } from './skill-cloud-service'
const { packaged } = vi.hoisted(() => ({ packaged: { value: false } }))
@@ -30,6 +31,41 @@ function userDataPath(): string {
return path
}
+function publishedVersion(archiveSha256: string, compressedBytes: number): SkillCloudVersion {
+ return {
+ packageId: 'pkg_retry',
+ versionId: 'ver_retry',
+ name: 'retry-skill',
+ description: 'Retry publication',
+ packageDigest: 'a'.repeat(64),
+ archiveSha256,
+ compressedBytes,
+ createdAt: '2026-08-12T12:00:00.000Z',
+ releaseNotes: 'retry',
+ manifest: {
+ schemaVersion: 1,
+ packageId: 'pkg_retry',
+ versionId: 'ver_retry',
+ name: 'retry-skill',
+ description: 'Retry publication',
+ createdAt: '2026-08-12T12:00:00.000Z',
+ packageDigest: 'a'.repeat(64),
+ files: []
+ }
+ }
+}
+
+function publishRequest(archivePath: string, archiveSha256: string, compressedBytes: number) {
+ return {
+ apiUrl: 'http://127.0.0.1:8787',
+ archivePath,
+ archiveSha256,
+ compressedBytes,
+ packageId: 'pkg_retry',
+ releaseNotes: 'retry'
+ }
+}
+
describe('SkillCloudService bearer links', () => {
it('resolves and grants downloads without an Orca session', async () => {
const requests: RequestInit[] = []
@@ -86,3 +122,126 @@ describe('SkillCloudService bearer links', () => {
).rejects.toThrow('available only in development builds')
})
})
+
+describe('SkillCloudService publication retries', () => {
+ it('reuses a reserved upload after its create response is lost', async () => {
+ vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token')
+ const root = userDataPath()
+ const archivePath = join(root, 'package.tar.gz')
+ const archive = Buffer.from('skill archive')
+ const archiveSha256 = 'b'.repeat(64)
+ const version = publishedVersion(archiveSha256, archive.byteLength)
+ writeFileSync(archivePath, archive)
+ let createAttempts = 0
+ let uploads = 0
+ const createKeys: string[] = []
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = String(input)
+ if (url.endsWith('/v1/skill-packages/uploads')) {
+ createAttempts += 1
+ createKeys.push(new Headers(init?.headers).get('idempotency-key') ?? '')
+ if (createAttempts === 1) {
+ throw new Error('create response lost')
+ }
+ return Response.json({
+ upload: {
+ id: 'upl_retry',
+ policy: { url: 'https://storage.test/upload', fields: {}, expiresAt: '2099-01-01' }
+ }
+ })
+ }
+ if (url === 'https://storage.test/upload') {
+ uploads += 1
+ return new Response(null, { status: 204 })
+ }
+ if (url.endsWith('/v1/skill-packages/pkg_retry')) {
+ return new Response(null, { status: 404 })
+ }
+ if (url.endsWith('/v1/skill-packages/uploads/upl_retry/finalize')) {
+ return Response.json({ version })
+ }
+ throw new Error(`Unexpected request: ${url}`)
+ })
+ )
+ const service = new SkillCloudService(root)
+ const request = publishRequest(archivePath, archiveSha256, archive.byteLength)
+
+ await expect(service.publishVersion(request)).rejects.toThrow('create response lost')
+ await expect(service.publishVersion(request)).resolves.toEqual({ status: 'ok', value: version })
+
+ expect(createKeys).toHaveLength(2)
+ expect(new Set(createKeys).size).toBe(1)
+ expect(createKeys[0]).not.toBe('')
+ expect(uploads).toBe(1)
+ })
+
+ it('finds the finalized version when retrying after its response is lost', async () => {
+ vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token')
+ const root = userDataPath()
+ const archivePath = join(root, 'package.tar.gz')
+ const archive = Buffer.from('skill archive')
+ const archiveSha256 = 'c'.repeat(64)
+ const version = publishedVersion(archiveSha256, archive.byteLength)
+ writeFileSync(archivePath, archive)
+ let createAttempts = 0
+ let uploads = 0
+ let finalizations = 0
+ const createKeys: string[] = []
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = String(input)
+ if (url.endsWith('/v1/skill-packages/uploads')) {
+ createAttempts += 1
+ createKeys.push(new Headers(init?.headers).get('idempotency-key') ?? '')
+ if (createAttempts === 2) {
+ return Response.json(
+ { code: 'skill_upload_not_pending', message: 'Upload is already finalized.' },
+ { status: 409 }
+ )
+ }
+ return Response.json({
+ upload: {
+ id: 'upl_retry',
+ policy: { url: 'https://storage.test/upload', fields: {}, expiresAt: '2099-01-01' }
+ }
+ })
+ }
+ if (url === 'https://storage.test/upload') {
+ uploads += 1
+ return new Response(null, { status: 204 })
+ }
+ if (url.endsWith('/v1/skill-packages/uploads/upl_retry/finalize')) {
+ finalizations += 1
+ throw new Error('finalize response lost')
+ }
+ if (url.endsWith('/v1/skill-packages/pkg_retry')) {
+ return Response.json({
+ package: {
+ id: 'pkg_retry',
+ name: 'retry-skill',
+ description: 'Retry publication',
+ createdAt: version.createdAt,
+ canManage: true,
+ versions: [version]
+ }
+ })
+ }
+ throw new Error(`Unexpected request: ${url}`)
+ })
+ )
+ const service = new SkillCloudService(root)
+ const request = publishRequest(archivePath, archiveSha256, archive.byteLength)
+
+ await expect(service.publishVersion(request)).rejects.toThrow('finalize response lost')
+ await expect(service.publishVersion(request)).resolves.toEqual({ status: 'ok', value: version })
+
+ expect(createKeys).toHaveLength(2)
+ expect(new Set(createKeys).size).toBe(1)
+ expect(createKeys[0]).not.toBe('')
+ expect(uploads).toBe(1)
+ expect(finalizations).toBe(1)
+ })
+})
diff --git a/src/main/skills/skill-cloud-service.ts b/src/main/skills/skill-cloud-service.ts
index d675b6624e7..9f87d9266c3 100644
--- a/src/main/skills/skill-cloud-service.ts
+++ b/src/main/skills/skill-cloud-service.ts
@@ -1,4 +1,4 @@
-import { randomUUID } from 'node:crypto'
+import { createHash, randomUUID } from 'node:crypto'
import type {
SkillCloudDownloadGrant,
SkillCloudInstallTarget,
@@ -19,6 +19,13 @@ import { startSkillPhaseOperation } from './skill-operation-observability'
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
+function publishUploadIdempotencyKey(request: SkillCloudPublishRequest): string {
+ return (
+ request.idempotencyKey ??
+ createHash('sha256').update(`${request.packageId}\0${request.archiveSha256}`).digest('hex')
+ )
+}
+
function id(value: string): string {
if (!ID_PATTERN.test(value)) {
throw new Error('skill-cloud-id-invalid')
@@ -46,23 +53,42 @@ export class SkillCloudService {
request: SkillCloudPublishRequest
): Promise> {
return this.withAuth(request, async (token, apiUrl) => {
- const upload = await skillCloudRequest<{
+ let upload: {
upload: {
id: string
policy: { url: string; fields: Record; expiresAt: string }
}
- }>({
- apiUrl,
- authToken: token,
- path: '/v1/skill-packages/uploads',
- method: 'POST',
- body: {
- expectedArchiveSha256: request.archiveSha256,
- expectedCompressedBytes: request.compressedBytes
- },
- idempotencyKey: randomUUID(),
- signal: request.signal
- })
+ }
+ try {
+ upload = await skillCloudRequest({
+ apiUrl,
+ authToken: token,
+ path: '/v1/skill-packages/uploads',
+ method: 'POST',
+ body: {
+ expectedArchiveSha256: request.archiveSha256,
+ expectedCompressedBytes: request.compressedBytes
+ },
+ idempotencyKey: publishUploadIdempotencyKey(request),
+ signal: request.signal
+ })
+ } catch (error) {
+ const existing = await skillCloudRequest<{ package: SkillCloudPackageDetails }>({
+ apiUrl,
+ authToken: token,
+ path: `/v1/skill-packages/${id(request.packageId)}`,
+ signal: request.signal
+ }).catch(() => null)
+ const published = existing?.package.versions.find(
+ (version) =>
+ version.archiveSha256 === request.archiveSha256 &&
+ version.compressedBytes === request.compressedBytes
+ )
+ if (published) {
+ return published
+ }
+ throw error
+ }
const uploadOperation = startSkillPhaseOperation({
phase: 'upload',
compressedBytes: request.compressedBytes
diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts
index ad457d00e81..7cc8bf020e1 100644
--- a/src/main/skills/skill-discovery-sources.ts
+++ b/src/main/skills/skill-discovery-sources.ts
@@ -10,6 +10,8 @@ import type {
import type { AgentType } from '../../shared/agent-status-types'
import type { Repo } from '../../shared/types'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
+import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots'
export type SkillScanRoot = Omit
type SkillDiscoveryPathApi = Pick
@@ -67,11 +69,14 @@ export function buildSkillDiscoverySources(
repos?: Repo[]
includeCwd?: boolean
pathApi?: SkillDiscoveryPathApi
+ providerRootOverrides?: SkillProviderRootOverrides
} = {}
): SkillScanRoot[] {
const pathApi = args.pathApi ?? { basename, join }
const home = args.homeDir ?? homedir()
const cwd = args.cwd ?? process.cwd()
+ const providerRootOverrides =
+ args.providerRootOverrides ?? (args.pathApi ? {} : resolveEnvironmentSkillProviderRoots())
const roots: SkillScanRoot[] = [
source(
'home-codex',
@@ -92,7 +97,7 @@ export function buildSkillDiscoverySources(
source(
'home-claude',
'Claude home',
- pathApi.join(home, '.claude', 'skills'),
+ providerRootOverrides.claude ?? pathApi.join(home, '.claude', 'skills'),
'home',
['claude'],
'claude'
@@ -110,7 +115,7 @@ export function buildSkillDiscoverySources(
source(
'home-grok',
'Grok home',
- pathApi.join(home, '.grok', 'skills'),
+ providerRootOverrides.grok ?? pathApi.join(home, '.grok', 'skills'),
'home',
['agent-skills'],
'grok'
@@ -170,6 +175,38 @@ export function buildSkillDiscoverySources(
'home',
['agent-skills'],
'cursor'
+ ),
+ source(
+ 'home-droid',
+ 'Droid home',
+ pathApi.join(home, '.factory', 'skills'),
+ 'home',
+ ['agent-skills'],
+ 'droid'
+ ),
+ source(
+ 'home-continue',
+ 'Continue home',
+ pathApi.join(home, '.continue', 'skills'),
+ 'home',
+ ['agent-skills'],
+ 'continue'
+ ),
+ source(
+ 'home-trae',
+ 'Trae home',
+ pathApi.join(home, '.trae-cn', 'skills'),
+ 'home',
+ ['agent-skills'],
+ 'trae'
+ ),
+ source(
+ 'home-aug',
+ 'Augment home',
+ pathApi.join(home, '.augment', 'skills'),
+ 'home',
+ ['agent-skills'],
+ 'aug'
)
]
@@ -204,6 +241,46 @@ export function buildSkillDiscoverySources(
'repo',
['claude'],
'claude'
+ ),
+ source(
+ `repo-droid-${stablePathId(repoPath)}`,
+ `${label} .factory`,
+ pathApi.join(repoPath, '.factory', 'skills'),
+ 'repo',
+ ['agent-skills'],
+ 'droid'
+ ),
+ source(
+ `repo-continue-${stablePathId(repoPath)}`,
+ `${label} .continue`,
+ pathApi.join(repoPath, '.continue', 'skills'),
+ 'repo',
+ ['agent-skills'],
+ 'continue'
+ ),
+ source(
+ `repo-trae-${stablePathId(repoPath)}`,
+ `${label} .trae`,
+ pathApi.join(repoPath, '.trae', 'skills'),
+ 'repo',
+ ['agent-skills'],
+ 'trae'
+ ),
+ source(
+ `repo-grok-${stablePathId(repoPath)}`,
+ `${label} .grok`,
+ pathApi.join(repoPath, '.grok', 'skills'),
+ 'repo',
+ ['agent-skills'],
+ 'grok'
+ ),
+ source(
+ `repo-aug-${stablePathId(repoPath)}`,
+ `${label} .augment`,
+ pathApi.join(repoPath, '.augment', 'skills'),
+ 'repo',
+ ['agent-skills'],
+ 'aug'
)
)
}
diff --git a/src/main/skills/skill-discovery-target.ts b/src/main/skills/skill-discovery-target.ts
index 2be10232c8c..72dc5546e2e 100644
--- a/src/main/skills/skill-discovery-target.ts
+++ b/src/main/skills/skill-discovery-target.ts
@@ -3,6 +3,7 @@ import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/sk
import { getDefaultWslDistro, getWslHome, parseWslPath, toLinuxPath } from '../wsl'
import { discoverSkills } from './discovery'
import { discoverSkillsInWsl } from './skill-discovery-wsl'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
export type ResolvedSkillDiscoveryTarget =
| { kind: 'native-host'; cwd: string | undefined }
@@ -55,16 +56,18 @@ export function resolveSkillDiscoveryTarget(
export async function discoverSkillsOnTarget(
target: ResolvedSkillDiscoveryTarget,
- repos: readonly Repo[]
+ repos: readonly Repo[],
+ providerRootOverrides?: SkillProviderRootOverrides
): Promise {
if (target.kind === 'wsl') {
return discoverSkillsInWsl({
distro: target.distro,
homeDir: target.homeDir,
- cwd: target.cwd
+ cwd: target.cwd,
+ providerRootOverrides
})
}
return target.cwd
- ? discoverSkills({ repos: [], cwd: target.cwd })
- : discoverSkills({ repos: [...repos] })
+ ? discoverSkills({ repos: [], cwd: target.cwd, providerRootOverrides })
+ : discoverSkills({ repos: [...repos], providerRootOverrides })
}
diff --git a/src/main/skills/skill-discovery-wsl.ts b/src/main/skills/skill-discovery-wsl.ts
index c4f553225ae..bfec78f24e3 100644
--- a/src/main/skills/skill-discovery-wsl.ts
+++ b/src/main/skills/skill-discovery-wsl.ts
@@ -16,6 +16,7 @@ import {
type SkillScanRoot
} from './skill-discovery-sources'
import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
const MAX_MARKDOWN_BYTES = 256 * 1024
const MAX_PACKAGE_FILES = 200
@@ -180,6 +181,7 @@ export async function discoverSkillsInWsl(args: {
distro: string
homeDir: string
cwd: string
+ providerRootOverrides?: SkillProviderRootOverrides
}): Promise {
// Plugin roots are resolved (in JS) from metadata this first wsl.exe call
// reads, then fed to the scan's own wsl.exe call below — two sequential
@@ -201,7 +203,8 @@ export async function discoverSkillsInWsl(args: {
homeDir: args.homeDir,
cwd: args.cwd,
repos: [],
- pathApi: pathPosix
+ pathApi: pathPosix,
+ providerRootOverrides: args.providerRootOverrides
}),
...pluginRoots
]
diff --git a/src/main/skills/skill-install-discovery-verification.test.ts b/src/main/skills/skill-install-discovery-verification.test.ts
index 2fc007e9b7d..5081890a08b 100644
--- a/src/main/skills/skill-install-discovery-verification.test.ts
+++ b/src/main/skills/skill-install-discovery-verification.test.ts
@@ -98,6 +98,31 @@ describe('WSL install discovery verification', () => {
})
})
+ it('scans a managed WSL Claude config root used by the target session', async () => {
+ const managedRoot = '/home/alice/.local/share/orca/claude-accounts/account-1/auth/skills'
+ discoverSkillsInWslMock.mockResolvedValue(
+ discovery(['/home/alice/.agents/skills', managedRoot])
+ )
+ const result = await verifySkillInstallDiscovery({
+ result: installResult([
+ '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.agents\\skills\\review',
+ '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\claude-accounts\\account-1\\auth\\skills\\review'
+ ]),
+ scope: 'global',
+ homeDirectory: '\\\\wsl.localhost\\Ubuntu\\home\\alice',
+ wslDistro: 'Ubuntu',
+ providerRootOverrides: {
+ claude:
+ '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\claude-accounts\\account-1\\auth\\skills'
+ }
+ })
+
+ expect(result.status).toBe('installed')
+ expect(discoverSkillsInWslMock).toHaveBeenCalledWith(
+ expect.objectContaining({ providerRootOverrides: { claude: managedRoot } })
+ )
+ })
+
it('fails verification without scanning a mismatched UNC distro', async () => {
const result = await verifySkillInstallDiscovery({
result: installResult(['\\\\wsl.localhost\\Debian\\home\\alice\\.agents\\skills\\review']),
diff --git a/src/main/skills/skill-install-discovery-verification.ts b/src/main/skills/skill-install-discovery-verification.ts
index debdd783887..9263c660627 100644
--- a/src/main/skills/skill-install-discovery-verification.ts
+++ b/src/main/skills/skill-install-discovery-verification.ts
@@ -4,6 +4,7 @@ import type { SkillDiscoveryResult } from '../../shared/skills'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { discoverSkills } from './discovery'
import { discoverSkillsInWsl } from './skill-discovery-wsl'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
function toWslDiscoveryPath(path: string, distro: string): string {
if (path.includes('\0')) {
@@ -65,12 +66,23 @@ async function discoverInstalledSkill(input: {
homeDirectory: string
workspaceDirectory?: string
wslDistro?: string
+ providerRootOverrides?: SkillProviderRootOverrides
}): Promise {
if (input.wslDistro) {
+ const wslDistro = input.wslDistro
+ const providerRootOverrides = input.providerRootOverrides
+ ? Object.fromEntries(
+ Object.entries(input.providerRootOverrides).map(([provider, root]) => [
+ provider,
+ toWslDiscoveryPath(root, wslDistro)
+ ])
+ )
+ : undefined
return discoverSkillsInWsl({
- distro: input.wslDistro,
- homeDir: toWslDiscoveryPath(input.homeDirectory, input.wslDistro),
- cwd: toWslDiscoveryPath(input.workspaceDirectory ?? input.homeDirectory, input.wslDistro)
+ distro: wslDistro,
+ homeDir: toWslDiscoveryPath(input.homeDirectory, wslDistro),
+ cwd: toWslDiscoveryPath(input.workspaceDirectory ?? input.homeDirectory, wslDistro),
+ ...(providerRootOverrides ? { providerRootOverrides } : {})
})
}
return discoverSkills({
@@ -78,7 +90,8 @@ async function discoverInstalledSkill(input: {
repos: [],
...(input.scope === 'workspace' && input.workspaceDirectory
? { cwd: input.workspaceDirectory }
- : { includeCwd: false })
+ : { includeCwd: false }),
+ ...(input.providerRootOverrides ? { providerRootOverrides: input.providerRootOverrides } : {})
})
}
@@ -88,6 +101,7 @@ export async function verifySkillInstallDiscovery(input: {
homeDirectory: string
workspaceDirectory?: string
wslDistro?: string
+ providerRootOverrides?: SkillProviderRootOverrides
discover?: () => Promise
}): Promise {
const discovery = await (input.discover?.() ?? discoverInstalledSkill(input)).catch(() => null)
diff --git a/src/main/skills/skill-install-filesystem.ts b/src/main/skills/skill-install-filesystem.ts
index 2c5169aca9b..67ab5d6d36f 100644
--- a/src/main/skills/skill-install-filesystem.ts
+++ b/src/main/skills/skill-install-filesystem.ts
@@ -10,6 +10,7 @@ import {
export type SkillInstalledFileMode = { path: string; executable: boolean }
export type SkillInstallFilesystem = {
+ authorizeRoots?(paths: readonly string[]): void
prepareExtractedSkill(path: string, manifest: SkillPackageManifestV1): Promise
observeSkill(
path: string,
diff --git a/src/main/skills/skill-install-historical-provider-roots.ts b/src/main/skills/skill-install-historical-provider-roots.ts
new file mode 100644
index 00000000000..4865a3cfce7
--- /dev/null
+++ b/src/main/skills/skill-install-historical-provider-roots.ts
@@ -0,0 +1,16 @@
+import { dirname } from 'node:path'
+import { isSkillInstallProviderId } from '../../shared/skill-install-providers'
+import type { SkillInstallReceiptV1 } from './skill-install-provenance'
+
+export function historicalSuccessfulProviderRoots(receipt: SkillInstallReceiptV1 | null): string[] {
+ return (
+ receipt?.placements.flatMap((placement) =>
+ isSkillInstallProviderId(placement.provider) &&
+ placement.topology !== 'canonical-copy' &&
+ placement.status !== 'failed' &&
+ placement.status !== 'skipped'
+ ? [dirname(placement.path)]
+ : []
+ ) ?? []
+ )
+}
diff --git a/src/main/skills/skill-install-journal-transition.ts b/src/main/skills/skill-install-journal-transition.ts
index 64a4b551a96..0768501f4f1 100644
--- a/src/main/skills/skill-install-journal-transition.ts
+++ b/src/main/skills/skill-install-journal-transition.ts
@@ -1,5 +1,6 @@
import { writeSkillStateFile } from './skill-install-provenance'
import type { SkillInstallJournalV1 } from './skill-install-recovery'
+import type { SkillInstallReceiptV1 } from './skill-install-provenance'
export type SkillInstallJournalBoundary = 'before' | 'after'
@@ -8,6 +9,11 @@ export type SkillInstallTransactionDependencies = {
phase: SkillInstallJournalV1['phase'],
boundary: SkillInstallJournalBoundary
) => Promise
+ placementTransaction?: {
+ prepare(previous: SkillInstallReceiptV1 | null, receipt: SkillInstallReceiptV1): Promise
+ commit(receipt: SkillInstallReceiptV1): Promise
+ finish(receipt: SkillInstallReceiptV1): Promise
+ }
}
export async function persistSkillInstallJournalTransition(
diff --git a/src/main/skills/skill-install-management-service.ts b/src/main/skills/skill-install-management-service.ts
index 1547eb031a5..7f496e9a98f 100644
--- a/src/main/skills/skill-install-management-service.ts
+++ b/src/main/skills/skill-install-management-service.ts
@@ -15,15 +15,22 @@ import type { SkillInstallDestinationAuthority } from './skill-install-destinati
import { resolveSkillInstallDestination } from './skill-install-destinations'
import { inspectSkillCanonicalState } from './skill-install-planner'
import { readSkillInstallReceipt } from './skill-install-provenance'
-import { resolveSkillProviderDestinations } from './skill-provider-destinations'
+import {
+ resolveSkillProviderDestinations,
+ type SkillProviderRootOverrides
+} from './skill-provider-destinations'
import { removeLocalSharedSkill } from './skill-remove-transaction'
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
+import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers'
type ManagementDependencies = {
authority: SkillInstallDestinationAuthority
stateDirectory: string
detectProviders(): Promise
+ resolveProviderRootOverrides?: (
+ destination: PreviewContext['destination']
+ ) => Promise | SkillProviderRootOverrides
}
type PreviewContext = {
@@ -31,6 +38,7 @@ type PreviewContext = {
filesystem: ReturnType | undefined
installStateDirectory: string
detectedProviders: readonly string[]
+ providerRootOverrides?: SkillProviderRootOverrides
}
const BUNDLE_PREVIEW_CONCURRENCY = 8
@@ -55,19 +63,22 @@ async function preparePreviewContext(
destinationRequest,
dependencies.authority
)
+ const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination)
return {
destination,
filesystem: destination.wslDistro
? createWslSkillInstallFilesystem({
distro: destination.wslDistro,
homeDirectory: destination.homeDirectory,
- workspaceDirectory: destination.workspaceDirectory
+ workspaceDirectory: destination.workspaceDirectory,
+ providerRootOverrides
})
: undefined,
installStateDirectory: join(dependencies.stateDirectory, 'skill-installs'),
detectedProviders: destination.wslDistro
? await detectSkillProvidersInWsl(destination.wslDistro)
- : await dependencies.detectProviders()
+ : await dependencies.detectProviders(),
+ providerRootOverrides
}
}
@@ -96,7 +107,8 @@ async function previewWithContext(
scope: context.destination.scope,
homeDirectory: context.destination.homeDirectory,
workspaceDirectory: context.destination.workspaceDirectory,
- detectedProviders: context.detectedProviders
+ detectedProviders: context.detectedProviders,
+ providerRootOverrides: context.providerRootOverrides
})
return {
name: request.name,
@@ -179,21 +191,21 @@ export async function removeSharedSkillInstall(
request.destination,
dependencies.authority
)
- const detectedProviders = destination.wslDistro
- ? await detectSkillProvidersInWsl(destination.wslDistro)
- : await dependencies.detectProviders()
+ const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination)
const filesystem = destination.wslDistro
? createWslSkillInstallFilesystem({
distro: destination.wslDistro,
homeDirectory: destination.homeDirectory,
- workspaceDirectory: destination.workspaceDirectory
+ workspaceDirectory: destination.workspaceDirectory,
+ providerRootOverrides
})
: undefined
const providerRoots = resolveSkillProviderDestinations({
scope: destination.scope,
homeDirectory: destination.homeDirectory,
workspaceDirectory: destination.workspaceDirectory,
- detectedProviders
+ detectedProviders: SKILL_INSTALL_PROVIDERS.map((provider) => provider.id),
+ providerRootOverrides
}).map((provider) => provider.rootPath)
return removeLocalSharedSkill({
operationId: request.operationId,
diff --git a/src/main/skills/skill-install-provenance.test.ts b/src/main/skills/skill-install-provenance.test.ts
index 61b67551217..6060702dba1 100644
--- a/src/main/skills/skill-install-provenance.test.ts
+++ b/src/main/skills/skill-install-provenance.test.ts
@@ -26,6 +26,7 @@ describe('managed skill install receipts', () => {
destinationIdentity: 'global:runtime-1',
canonicalPath: join(root, 'missing', 'example'),
placements: [],
+ providers: ['codex', 'claude'],
installedAt: '2026-08-11T00:00:00.000Z',
hostIdentity: 'runtime-1'
})
@@ -35,6 +36,7 @@ describe('managed skill install receipts', () => {
name: 'example',
packageId: 'package-1',
versionId: 'version-1',
+ providers: ['codex', 'claude'],
state: 'missing'
})
])
diff --git a/src/main/skills/skill-install-provenance.ts b/src/main/skills/skill-install-provenance.ts
index 928f0cb9958..42b6c07f545 100644
--- a/src/main/skills/skill-install-provenance.ts
+++ b/src/main/skills/skill-install-provenance.ts
@@ -17,6 +17,7 @@ export type SkillInstallReceiptV1 = {
destinationIdentity: string
canonicalPath: string
placements: SkillPlacementResult[]
+ providers?: string[]
previousVersionId?: string
installedAt: string
hostIdentity: string
@@ -67,6 +68,9 @@ function isReceipt(value: unknown): value is SkillInstallReceiptV1 {
typeof receipt.destinationIdentity === 'string' &&
typeof receipt.canonicalPath === 'string' &&
Array.isArray(receipt.placements) &&
+ (receipt.providers === undefined ||
+ (Array.isArray(receipt.providers) &&
+ receipt.providers.every((provider) => typeof provider === 'string'))) &&
typeof receipt.installedAt === 'string' &&
typeof receipt.hostIdentity === 'string' &&
(receipt.fileModes === undefined ||
@@ -110,6 +114,7 @@ export async function listManagedSkillInstalls(
scope: parsed.scope,
destinationIdentity: parsed.destinationIdentity,
installedAt: parsed.installedAt,
+ ...(parsed.providers ? { providers: parsed.providers } : {}),
state: observed
? observed.observedDigest === parsed.packageDigest
? 'unchanged'
diff --git a/src/main/skills/skill-install-request-service.ts b/src/main/skills/skill-install-request-service.ts
index dcd617a0923..192bebc3709 100644
--- a/src/main/skills/skill-install-request-service.ts
+++ b/src/main/skills/skill-install-request-service.ts
@@ -11,9 +11,11 @@ import {
import { installSharedSkill } from './skill-install-service'
import { downloadSkillPackageGrant } from './skill-package-download'
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
+import { selectedOrDetectedSkillProviders } from '../../shared/skill-install-providers'
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
import { skillInstallFailureFromError } from './skill-install-operation-error'
import { startSkillInstallOperation } from './skill-operation-observability'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
type StagedSkillPackage = {
archivePath: string
@@ -29,6 +31,12 @@ export type SkillInstallRequestDependencies = {
signal?: AbortSignal
fetcher?: typeof fetch
detectProviders: () => Promise
+ resolveProviderRootOverrides?: (destination: {
+ scope: 'global' | 'workspace'
+ homeDirectory: string
+ workspaceDirectory?: string
+ wslDistro?: string
+ }) => Promise | SkillProviderRootOverrides
resolveStagedUpload?: (
uploadId: string,
identity: SkillInstallRequest['package']
@@ -91,14 +99,19 @@ async function executeParsedSkillInstallRequest(
let ingress: StagedSkillPackage | null = null
try {
ingress = await resolveIngress(request, dependencies)
- const detectedProviders = destination.wslDistro
- ? await detectSkillProvidersInWsl(destination.wslDistro)
- : await dependencies.detectProviders()
+ const detectedProviders = selectedOrDetectedSkillProviders(
+ destination.wslDistro
+ ? await detectSkillProvidersInWsl(destination.wslDistro)
+ : await dependencies.detectProviders(),
+ request.providers
+ )
+ const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination)
const filesystem = destination.wslDistro
? createWslSkillInstallFilesystem({
distro: destination.wslDistro,
homeDirectory: destination.homeDirectory,
- workspaceDirectory: destination.workspaceDirectory
+ workspaceDirectory: destination.workspaceDirectory,
+ providerRootOverrides
})
: undefined
return await installSharedSkill({
@@ -109,6 +122,7 @@ async function executeParsedSkillInstallRequest(
workspaceDirectory: destination.workspaceDirectory,
orcaStateDirectory: dependencies.stateDirectory,
detectedProviders,
+ providerRootOverrides,
destinationIdentity: destination.destinationIdentity,
hostIdentity: dependencies.authority.environmentId,
expectedArchiveSha256: request.package.archiveSha256,
diff --git a/src/main/skills/skill-install-service.test.ts b/src/main/skills/skill-install-service.test.ts
index aed3879ab93..ae47e88b6b8 100644
--- a/src/main/skills/skill-install-service.test.ts
+++ b/src/main/skills/skill-install-service.test.ts
@@ -1,7 +1,7 @@
-import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
+import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSkillPackageArchive } from './skill-package-creation'
import {
installSharedSkill,
@@ -56,7 +56,7 @@ afterEach(async () => {
})
describe('skill install service', () => {
- it('installs one canonical copy and aliases Claude to it', async () => {
+ it('installs one canonical Codex copy and aliases the other agent home to it', async () => {
const { root, input } = await fixture()
const result = await installSharedSkill(input)
const canonical = join(root, 'home', '.agents', 'skills', 'test-skill')
@@ -67,6 +67,27 @@ describe('skill install service', () => {
expect(await realpath(claude)).toBe(await realpath(canonical))
})
+ it('authorizes a historical provider root before relocating it', async () => {
+ const { root, input } = await fixture()
+ const oldRoot = join(root, 'old-claude', 'skills')
+ await installSharedSkill({
+ ...input,
+ detectedProviders: ['claude'],
+ providerRootOverrides: { claude: oldRoot },
+ filesystem: { ...nativeSkillInstallFilesystem }
+ })
+ const authorizeRoots = vi.fn()
+
+ await installSharedSkill({
+ ...input,
+ detectedProviders: ['claude'],
+ providerRootOverrides: { claude: join(root, 'new-claude', 'skills') },
+ filesystem: { ...nativeSkillInstallFilesystem, authorizeRoots }
+ })
+
+ expect(authorizeRoots).toHaveBeenCalledWith([oldRoot])
+ })
+
it('leaves an unowned provider copy untouched and reports partial coverage', async () => {
const { root, input } = await fixture()
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
@@ -84,6 +105,51 @@ describe('skill install service', () => {
expect(await readFile(join(claude, 'SKILL.md'), 'utf8')).toBe('unowned')
})
+ it('never removes a byte-identical provider copy that was recorded as unowned', async () => {
+ const { root, input } = await fixture()
+ const sourceSkill = join(root, 'source')
+ const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
+ await mkdir(join(root, 'home', '.claude', 'skills'), { recursive: true })
+ await cp(sourceSkill, claude, { recursive: true })
+ const installed = await installSharedSkill(input)
+ expect(installed.placements).toContainEqual(
+ expect.objectContaining({ provider: 'claude', status: 'skipped' })
+ )
+
+ const removed = await removeSharedSkill({
+ operationId: 'remove_identical_unowned',
+ skillName: 'test-skill',
+ scope: 'global',
+ homeDirectory: input.homeDirectory,
+ orcaStateDirectory: input.orcaStateDirectory,
+ detectedProviders: input.detectedProviders
+ })
+
+ expect(removed.status).toBe('partial')
+ expect(await readFile(join(claude, 'SKILL.md'), 'utf8')).toContain('# Test')
+ })
+
+ it('keeps an unchanged canonical install partial when a provider placement is unowned', async () => {
+ const { root, input } = await fixture()
+ await installSharedSkill({ ...input, detectedProviders: ['codex'] })
+ const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
+ await mkdir(claude, { recursive: true })
+ await writeFile(join(claude, 'SKILL.md'), 'unowned')
+
+ const result = await installSharedSkill(input)
+
+ expect(result).toMatchObject({
+ status: 'partial',
+ placements: expect.arrayContaining([
+ expect.objectContaining({
+ provider: 'claude',
+ status: 'skipped',
+ errorCategory: 'skill-placement-unowned'
+ })
+ ])
+ })
+ })
+
it('does not report success when normal skill discovery cannot observe the canonical copy', async () => {
const { root, input } = await fixture()
@@ -102,7 +168,7 @@ describe('skill install service', () => {
).toContain('# Test')
})
- it('removes the canonical skill and its owned provider alias', async () => {
+ it('removes owned placements even after the provider is no longer detected', async () => {
const { root, input } = await fixture()
await installSharedSkill(input)
@@ -112,7 +178,7 @@ describe('skill install service', () => {
scope: 'global',
homeDirectory: input.homeDirectory,
orcaStateDirectory: input.orcaStateDirectory,
- detectedProviders: input.detectedProviders
+ detectedProviders: []
})
expect(result.status).toBe('removed')
@@ -128,6 +194,24 @@ describe('skill install service', () => {
})
})
+ it('removes an owned placement when its provider is deselected', async () => {
+ const { root, input } = await fixture()
+ await installSharedSkill(input)
+ const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
+
+ const updated = await installSharedSkill({
+ ...input,
+ operationId: 'operation_without_claude',
+ detectedProviders: ['codex']
+ })
+
+ expect(updated.status).toBe('unchanged')
+ await expect(lstat(claude)).rejects.toMatchObject({ code: 'ENOENT' })
+ expect(updated.placements).toEqual([
+ expect.objectContaining({ provider: 'agent-skills', topology: 'canonical-copy' })
+ ])
+ })
+
it('preserves modified canonical and provider content during removal', async () => {
const { root, input } = await fixture()
await installSharedSkill(input)
@@ -234,7 +318,6 @@ describe('skill install service', () => {
})
])
)
- expect(interrupted.placements.some((placement) => placement.provider === 'codex')).toBe(false)
expect(await readFile(join(canonical, 'SKILL.md'), 'utf8')).toContain('# Test')
const retried = await installSharedSkill({ ...input, operationId: 'operation_retry' })
diff --git a/src/main/skills/skill-install-service.ts b/src/main/skills/skill-install-service.ts
index eacf1cefbf0..1e62d42eb4f 100644
--- a/src/main/skills/skill-install-service.ts
+++ b/src/main/skills/skill-install-service.ts
@@ -1,6 +1,5 @@
import { join } from 'node:path'
-import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract'
-import { readSkillInstallReceipt, writeSkillInstallReceipt } from './skill-install-provenance'
+import type { SkillInstallResult } from '../../shared/skill-install-contract'
import {
installLocalExtractedSkillPackage,
installLocalSkillPackage,
@@ -8,13 +7,16 @@ import {
type LocalSkillInstallInput,
previewLocalSkillPackage
} from './skill-install-transaction'
-import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
-import { resolveSkillProviderDestinations } from './skill-provider-destinations'
+import {
+ resolveSkillProviderDestinations,
+ type SkillProviderRootOverrides
+} from './skill-provider-destinations'
import { removeLocalSharedSkill } from './skill-remove-transaction'
import type { SkillInstallFilesystem } from './skill-install-filesystem'
import { verifySkillInstallDiscovery } from './skill-install-discovery-verification'
import type { SkillDiscoveryResult } from '../../shared/skills'
-import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest'
+import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers'
+import { createSkillPlacementTransaction } from './skill-placement-transaction-controller'
export type SkillInstallServiceInput = Omit<
LocalSkillInstallInput,
@@ -25,6 +27,7 @@ export type SkillInstallServiceInput = Omit<
workspaceDirectory?: string
orcaStateDirectory: string
detectedProviders: readonly string[]
+ providerRootOverrides?: SkillProviderRootOverrides
filesystem?: SkillInstallFilesystem
wslDistro?: string
discover?: () => Promise
@@ -39,6 +42,7 @@ export type SkillRemoveServiceInput = {
workspaceDirectory?: string
orcaStateDirectory: string
detectedProviders: readonly string[]
+ providerRootOverrides?: SkillProviderRootOverrides
conflictResolution?: 'replace-and-discard-local' | 'cancel'
filesystem?: SkillInstallFilesystem
}
@@ -76,32 +80,29 @@ export function skillInstallLocalInput(input: SkillInstallServiceInput): LocalSk
}
}
-function canonicalPlacement(result: SkillInstallResult): SkillPlacementResult {
- return {
- provider: 'agent-skills',
- path: result.canonicalPath!,
- topology: 'canonical-copy',
- status: result.status === 'unchanged' ? 'unchanged' : 'installed'
- }
+function placementTransaction(input: SkillInstallServiceInput, request: LocalSkillInstallInput) {
+ return createSkillPlacementTransaction({
+ stateDirectory: request.stateDirectory,
+ scope: input.scope,
+ homeDirectory: input.homeDirectory,
+ workspaceDirectory: input.workspaceDirectory,
+ detectedProviders: input.detectedProviders,
+ providerRootOverrides: input.providerRootOverrides,
+ filesystem: input.filesystem,
+ wslDistro: input.wslDistro,
+ signal: input.signal
+ })
}
export async function installSharedSkill(
input: SkillInstallServiceInput
): Promise {
const request = skillInstallLocalInput(input)
- const preview = await previewLocalSkillPackage(request)
- const previousReceipt = await readSkillInstallReceipt(
- request.stateDirectory,
- preview.canonicalPath
- )
- const result = await installLocalSkillPackage(request)
- return completeSharedSkillInstall({
- input,
- request,
- manifest: preview.manifest,
- previousReceipt,
- result
+ await previewLocalSkillPackage(request)
+ const result = await installLocalSkillPackage(request, {
+ placementTransaction: placementTransaction(input, request)
})
+ return completeSharedSkillInstall(input, result)
}
export async function installSharedExtractedSkill(
@@ -109,26 +110,16 @@ export async function installSharedExtractedSkill(
extracted: LocalExtractedSkillPackage
): Promise {
const request = skillInstallLocalInput(input)
- const canonicalPath = join(request.destinationRoot, extracted.manifest.name)
- const previousReceipt = await readSkillInstallReceipt(request.stateDirectory, canonicalPath)
- const result = await installLocalExtractedSkillPackage(request, extracted)
- return completeSharedSkillInstall({
- input,
- request,
- manifest: extracted.manifest,
- previousReceipt,
- result
+ const result = await installLocalExtractedSkillPackage(request, extracted, {
+ placementTransaction: placementTransaction(input, request)
})
+ return completeSharedSkillInstall(input, result)
}
-async function completeSharedSkillInstall(input: {
- input: SkillInstallServiceInput
- request: LocalSkillInstallInput
- manifest: SkillPackageManifestV1
- previousReceipt: Awaited>
+async function completeSharedSkillInstall(
+ input: SkillInstallServiceInput,
result: SkillInstallResult
-}): Promise {
- const { result } = input
+): Promise {
if (
!result.canonicalPath ||
result.status === 'conflict' ||
@@ -137,61 +128,17 @@ async function completeSharedSkillInstall(input: {
) {
return result
}
- const placements: SkillPlacementResult[] = [canonicalPlacement(result)]
- const destinations = resolveSkillProviderDestinations({
- scope: input.input.scope,
- homeDirectory: input.input.homeDirectory,
- workspaceDirectory: input.input.workspaceDirectory,
- detectedProviders: input.input.detectedProviders
- })
- for (const destination of destinations) {
- if (destination.readsCanonicalRoot) {
- continue
- }
- if (input.input.signal?.aborted) {
- placements.push({
- provider: destination.provider,
- path: join(destination.rootPath, result.name),
- topology: 'independent-copy',
- status: 'skipped',
- errorCategory: 'skill-placement-cancelled',
- failure: {
- category: 'cancelled',
- code: 'skill-placement-cancelled',
- retryable: true
- }
- })
- continue
- }
- const placement = await reconcileSkillProviderPlacement({
- canonicalPath: result.canonicalPath,
- skillName: result.name,
- destination,
- previousReceipt: input.previousReceipt,
- packageDigest: result.packageDigest,
- fileModes: input.manifest.files,
- filesystem: input.input.filesystem,
- ...(input.input.wslDistro ? { targetPlatform: 'linux' as const } : {})
- })
- if (placement) {
- placements.push(placement)
- }
- }
- const receipt = await readSkillInstallReceipt(input.request.stateDirectory, result.canonicalPath)
- if (!receipt) {
- throw new Error('skill-install-receipt-missing')
- }
- await writeSkillInstallReceipt(input.request.stateDirectory, { ...receipt, placements })
- const incomplete = placements.some(
+ const incomplete = result.placements.some(
(placement) => placement.status === 'failed' || placement.status === 'skipped'
)
return verifySkillInstallDiscovery({
- result: { ...result, status: incomplete ? 'partial' : result.status, placements },
- scope: input.input.scope,
- homeDirectory: input.input.homeDirectory,
- workspaceDirectory: input.input.workspaceDirectory,
- wslDistro: input.input.wslDistro,
- discover: input.input.discover
+ result: { ...result, status: incomplete ? 'partial' : result.status },
+ scope: input.scope,
+ homeDirectory: input.homeDirectory,
+ workspaceDirectory: input.workspaceDirectory,
+ wslDistro: input.wslDistro,
+ providerRootOverrides: input.providerRootOverrides,
+ discover: input.discover
})
}
@@ -205,7 +152,8 @@ export async function removeSharedSkill(
scope: input.scope,
homeDirectory: input.homeDirectory,
workspaceDirectory: input.workspaceDirectory,
- detectedProviders: input.detectedProviders
+ detectedProviders: SKILL_INSTALL_PROVIDERS.map((provider) => provider.id),
+ providerRootOverrides: input.providerRootOverrides
})
return removeLocalSharedSkill({
operationId: input.operationId,
diff --git a/src/main/skills/skill-install-transaction-result.ts b/src/main/skills/skill-install-transaction-result.ts
index 2b580491298..7df4cc96ce7 100644
--- a/src/main/skills/skill-install-transaction-result.ts
+++ b/src/main/skills/skill-install-transaction-result.ts
@@ -68,6 +68,24 @@ export function skillInstallReplacementAllowed(
)
}
+export function skillInstallUnchangedResult(
+ input: LocalSkillInstallInput,
+ manifest: SkillPackageManifestV1,
+ canonicalPath: string,
+ receipt: SkillInstallReceiptV1
+): SkillInstallResult {
+ return {
+ operationId: input.operationId,
+ status: 'unchanged',
+ name: manifest.name,
+ packageDigest: manifest.packageDigest,
+ canonicalPath,
+ placements: receipt.placements.map((placement) =>
+ placement.status === 'installed' ? { ...placement, status: 'unchanged' } : placement
+ )
+ }
+}
+
export function createSkillInstallReceipt(input: {
request: LocalSkillInstallInput
manifest: SkillPackageManifestV1
diff --git a/src/main/skills/skill-install-transaction.ts b/src/main/skills/skill-install-transaction.ts
index 1410a6d585f..d3464490585 100644
--- a/src/main/skills/skill-install-transaction.ts
+++ b/src/main/skills/skill-install-transaction.ts
@@ -34,15 +34,12 @@ import {
createSkillInstallReceipt,
skillInstallConflictResult,
skillInstallFailureResult,
- skillInstallReplacementAllowed
+ skillInstallReplacementAllowed,
+ skillInstallUnchangedResult
} from './skill-install-transaction-result'
import { settleObservedSkillTransactionRecovery } from './skill-transaction-recovery-observability'
-
-export { recoverSkillInstallTransaction } from './skill-install-recovery'
-export type {
- SkillInstallJournalBoundary,
- SkillInstallTransactionDependencies
-} from './skill-install-journal-transition'
+import { recoverSkillPlacementTransaction } from './skill-placement-transaction'
+import { historicalSuccessfulProviderRoots } from './skill-install-historical-provider-roots'
export type LocalSkillInstallInput = {
operationId: string
@@ -168,8 +165,11 @@ export async function installLocalExtractedSkillPackage(
path: skillInstallLockPath(input.stateDirectory, canonicalPath),
timeoutMs: input.lockTimeoutMs
})
+ const historicalReceipt = await readSkillInstallReceipt(input.stateDirectory, canonicalPath)
+ filesystem.authorizeRoots?.(historicalSuccessfulProviderRoots(historicalReceipt))
await recoverSkillRemovalTransaction(input.stateDirectory, canonicalPath, filesystem)
await recoverSkillInstallTransaction(input.stateDirectory, canonicalPath, filesystem)
+ await recoverSkillPlacementTransaction(input.stateDirectory, canonicalPath, filesystem)
throwIfCancelled(input.signal)
const previous = await readSkillInstallReceipt(input.stateDirectory, canonicalPath)
const state = await inspectSkillCanonicalState({
@@ -178,7 +178,7 @@ export async function installLocalExtractedSkillPackage(
receipt: previous,
filesystem
})
- const receipt = createSkillInstallReceipt({
+ let receipt = createSkillInstallReceipt({
request: input,
manifest: extracted.manifest,
archiveSha256: extracted.archiveSha256,
@@ -186,23 +186,17 @@ export async function installLocalExtractedSkillPackage(
previous
})
if (state.kind === 'unchanged') {
+ await dependencies.placementTransaction?.prepare(previous, receipt)
throwIfCancelled(input.signal)
+ receipt = (await dependencies.placementTransaction?.commit(receipt)) ?? receipt
await writeSkillInstallReceipt(input.stateDirectory, receipt)
- return {
- operationId: input.operationId,
- status: 'unchanged',
- name: extracted.manifest.name,
- packageDigest: extracted.manifest.packageDigest,
- canonicalPath,
- placements: receipt.placements.map((placement) => ({
- ...placement,
- status: 'unchanged'
- }))
- }
+ await dependencies.placementTransaction?.finish(receipt)
+ return skillInstallUnchangedResult(input, extracted.manifest, canonicalPath, receipt)
}
if (!skillInstallReplacementAllowed(state, input)) {
return skillInstallConflictResult(input.operationId, extracted.manifest, state)
}
+ await dependencies.placementTransaction?.prepare(previous, receipt)
throwIfCancelled(input.signal)
const transactionId = randomUUID()
const stagingPath = join(
@@ -271,8 +265,12 @@ export async function installLocalExtractedSkillPackage(
throw new Error('skill-install-committed-digest-mismatch')
}
await writeSkillInstallReceipt(input.stateDirectory, receipt)
+ receipt = (await dependencies.placementTransaction?.commit(receipt)) ?? receipt
+ await writeSkillInstallReceipt(input.stateDirectory, receipt)
+ journal.receipt = receipt
journal.phase = 'receipt-published'
await persistSkillInstallJournalTransition(statePath, journal, dependencies)
+ await dependencies.placementTransaction?.finish(receipt)
await cleanSkillInstallJournalFiles(journal, filesystem)
journal.phase = 'complete'
await persistSkillInstallJournalTransition(statePath, journal, dependencies)
diff --git a/src/main/skills/skill-operation-observability.ts b/src/main/skills/skill-operation-observability.ts
index c0cddfda155..6fd18dbe6b0 100644
--- a/src/main/skills/skill-operation-observability.ts
+++ b/src/main/skills/skill-operation-observability.ts
@@ -4,6 +4,7 @@ import type {
SkillInstallResult,
SkillPlacementResult
} from '../../shared/skill-install-contract'
+import type { SkillInstallProviderId } from '../../shared/skill-install-providers'
import type {
SkillBundleInstallRequest,
SkillBundleInstallResult
@@ -33,7 +34,7 @@ type SkillPhaseStart = {
destination?: 'startup' | 'transaction' | 'provider-placement' | 'remote-runtime' | 'global-ssh'
compressedBytes?: number
skillCount?: number
- provider?: 'codex' | 'claude'
+ provider?: SkillInstallProviderId
}
type SkillPhaseOutcome = {
diff --git a/src/main/skills/skill-package-download-availability.ts b/src/main/skills/skill-package-download-availability.ts
new file mode 100644
index 00000000000..db6a92ab862
--- /dev/null
+++ b/src/main/skills/skill-package-download-availability.ts
@@ -0,0 +1,53 @@
+import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure'
+import { SkillInstallOperationError } from './skill-install-operation-error'
+
+const MAX_TIMEOUT_DELAY_MS = 2_147_483_647
+
+export function throwIfSkillDownloadUnavailable(input: {
+ signal?: AbortSignal
+ now: () => number
+ expiresAt: number
+}): void {
+ if (input.signal?.aborted) {
+ throw new SkillInstallOperationError({
+ ...SKILL_INSTALL_CANCELLED_FAILURE,
+ code: 'skill-download-cancelled'
+ })
+ }
+ if (input.now() >= input.expiresAt) {
+ throw new Error('skill-download-grant-expired')
+ }
+}
+
+export function isSkillDownloadGrantExpiredAbort(signal: AbortSignal | undefined): boolean {
+ return signal?.reason instanceof Error && signal.reason.message === 'skill-download-grant-expired'
+}
+
+export function createSkillDownloadAvailabilitySignal(input: {
+ signal?: AbortSignal
+ now: () => number
+ expiresAt: number
+}): {
+ signal: AbortSignal
+ cleanup(): void
+} {
+ const controller = new AbortController()
+ const abortFromCaller = (): void => controller.abort(input.signal?.reason)
+ if (input.signal?.aborted) {
+ abortFromCaller()
+ } else {
+ input.signal?.addEventListener('abort', abortFromCaller, { once: true })
+ }
+ const timeout = setTimeout(
+ () => controller.abort(new Error('skill-download-grant-expired')),
+ Math.min(MAX_TIMEOUT_DELAY_MS, Math.max(0, input.expiresAt - input.now()))
+ )
+ timeout.unref()
+ return {
+ signal: controller.signal,
+ cleanup: () => {
+ clearTimeout(timeout)
+ input.signal?.removeEventListener('abort', abortFromCaller)
+ }
+ }
+}
diff --git a/src/main/skills/skill-package-download.test.ts b/src/main/skills/skill-package-download.test.ts
index 20dce93a8a4..0d869c6bdb2 100644
--- a/src/main/skills/skill-package-download.test.ts
+++ b/src/main/skills/skill-package-download.test.ts
@@ -183,6 +183,54 @@ describe('downloadSkillPackageGrant', () => {
expect(expired.fetcher).not.toHaveBeenCalled()
})
+ it('physically aborts a fetch stalled past grant expiry', async () => {
+ vi.useFakeTimers()
+ const stalled = await input({
+ expiresAt: new Date(Date.now() + 100).toISOString(),
+ fetcher: vi.fn(
+ async (_input: URL | RequestInfo, init?: RequestInit) =>
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
+ once: true
+ })
+ })
+ ) as typeof fetch
+ })
+
+ const download = downloadSkillPackageGrant(stalled)
+ const expectation = expect(download).rejects.toThrow('skill-download-grant-expired')
+ await Promise.resolve()
+ await vi.advanceTimersByTimeAsync(100)
+ await expectation
+ vi.useRealTimers()
+ })
+
+ it('does not expire a far-future stalled fetch immediately when the timer delay overflows Node limits', async () => {
+ vi.useFakeTimers()
+ const caller = new AbortController()
+ const stalled = await input({
+ signal: caller.signal,
+ expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000 * 50).toISOString(),
+ fetcher: vi.fn(
+ async (_input: URL | RequestInfo, init?: RequestInit) =>
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
+ once: true
+ })
+ })
+ ) as typeof fetch
+ })
+
+ const download = downloadSkillPackageGrant(stalled)
+ await Promise.resolve()
+ await vi.advanceTimersByTimeAsync(10)
+ await expect(Promise.race([download, Promise.resolve('pending')])).resolves.toBe('pending')
+
+ caller.abort()
+ await expect(download).rejects.toThrow('skill-download-cancelled')
+ vi.useRealTimers()
+ })
+
it('deletes partial bytes when a streaming download is cancelled', async () => {
const controller = new AbortController()
let sent = false
@@ -207,4 +255,35 @@ describe('downloadSkillPackageGrant', () => {
await expect(downloadSkillPackageGrant(cancelled)).rejects.toThrow('skill-download-cancelled')
expect(await remainingDownloadFiles(cancelled.temporaryRoot)).toEqual([])
})
+
+ it('physically aborts a body read stalled past grant expiry and deletes partial bytes', async () => {
+ vi.useFakeTimers()
+ const stalled = await input({
+ expiresAt: new Date(Date.now() + 100).toISOString(),
+ fetcher: vi.fn(async (_input: URL | RequestInfo, init?: RequestInit) => {
+ let controller!: ReadableStreamDefaultController
+ init?.signal?.addEventListener(
+ 'abort',
+ () => controller.error(init.signal?.reason ?? new Error('aborted')),
+ { once: true }
+ )
+ return response(
+ new ReadableStream({
+ start(streamController) {
+ controller = streamController
+ controller.enqueue(bytes.subarray(0, 4))
+ }
+ })
+ )
+ }) as typeof fetch
+ })
+
+ const download = downloadSkillPackageGrant(stalled)
+ const expectation = expect(download).rejects.toThrow('skill-download-grant-expired')
+ await Promise.resolve()
+ await vi.advanceTimersByTimeAsync(100)
+ await expectation
+ expect(await remainingDownloadFiles(stalled.temporaryRoot)).toEqual([])
+ vi.useRealTimers()
+ })
})
diff --git a/src/main/skills/skill-package-download.ts b/src/main/skills/skill-package-download.ts
index 9fe58c8530d..595b568eeff 100644
--- a/src/main/skills/skill-package-download.ts
+++ b/src/main/skills/skill-package-download.ts
@@ -5,8 +5,11 @@ import {
SKILL_PACKAGE_CONTENT_TYPE,
SKILL_PACKAGE_MAX_COMPRESSED_BYTES
} from '../../shared/skill-package-manifest'
-import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure'
-import { SkillInstallOperationError } from './skill-install-operation-error'
+import {
+ createSkillDownloadAvailabilitySignal,
+ isSkillDownloadGrantExpiredAbort,
+ throwIfSkillDownloadUnavailable
+} from './skill-package-download-availability'
import { startSkillPhaseOperation } from './skill-operation-observability'
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
@@ -63,40 +66,37 @@ function validateUrl(
return url
}
-function throwIfUnavailable(input: SkillPackageDownloadInput, expiresAt: number): void {
- if (input.signal?.aborted) {
- throw new SkillInstallOperationError({
- ...SKILL_INSTALL_CANCELLED_FAILURE,
- code: 'skill-download-cancelled'
- })
- }
- if (input.now!() >= expiresAt) {
- throw new Error('skill-download-grant-expired')
- }
-}
-
async function fetchWithoutCredentialRedirect(
input: SkillPackageDownloadInput,
allowedOrigins: ReadonlySet,
- expiresAt: number
+ expiresAt: number,
+ signal: AbortSignal
): Promise {
let url = validateUrl(input.url, allowedOrigins, input.requireHttps)
for (let redirectCount = 0; ; redirectCount += 1) {
- throwIfUnavailable(input, expiresAt)
+ throwIfSkillDownloadUnavailable({
+ signal: input.signal,
+ now: input.now!,
+ expiresAt
+ })
let response: Response
try {
response = await input.fetcher!(url, {
method: 'GET',
redirect: 'manual',
- signal: input.signal
+ signal
})
} catch {
if (input.signal?.aborted) {
- throw new SkillInstallOperationError({
- ...SKILL_INSTALL_CANCELLED_FAILURE,
- code: 'skill-download-cancelled'
+ throwIfSkillDownloadUnavailable({
+ signal: input.signal,
+ now: input.now!,
+ expiresAt
})
}
+ if (isSkillDownloadGrantExpiredAbort(signal)) {
+ throw new Error('skill-download-grant-expired')
+ }
throw new Error('skill-download-transport-failed')
}
if (!REDIRECT_STATUSES.has(response.status)) {
@@ -181,80 +181,102 @@ async function downloadSkillPackageGrantUnobserved(
(origin) => validateUrl(origin, new Set([new URL(origin).origin]), false).origin
)
)
- throwIfUnavailable(input, expiresAt)
- const response = await fetchWithoutCredentialRedirect(input, allowedOrigins, expiresAt)
- if (!response.ok || !response.body) {
- throw new Error('skill-download-transport-failed')
- }
- if (response.headers.get('content-type')?.split(';', 1)[0] !== SKILL_PACKAGE_CONTENT_TYPE) {
- throw new Error('skill-download-content-type-invalid')
- }
- const contentLength = response.headers.get('content-length')
- if (contentLength !== null && Number(contentLength) !== input.expectedCompressedBytes) {
- throw new Error('skill-download-size-mismatch')
- }
-
- const processRoot = await prepareTemporaryRoot(input.temporaryRoot)
- const temporaryDirectory = await mkdtemp(join(processRoot, '.orca-skill-download-'))
- const archivePath = join(temporaryDirectory, 'package.tar.gz')
+ throwIfSkillDownloadUnavailable({ signal: input.signal, now: input.now!, expiresAt })
+ const availability = createSkillDownloadAvailabilitySignal({
+ signal: input.signal,
+ now: input.now!,
+ expiresAt
+ })
try {
- const handle = await open(archivePath, 'wx', 0o600)
- const reader = response.body.getReader()
- const hash = createHash('sha256')
- let compressedBytes = 0
- try {
- for (;;) {
- throwIfUnavailable(input, expiresAt)
- const chunk = await reader.read()
- if (chunk.done) {
- break
- }
- compressedBytes += chunk.value.byteLength
- if (
- compressedBytes > input.expectedCompressedBytes ||
- compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES
- ) {
- throw new Error('skill-download-size-limit')
- }
- hash.update(chunk.value)
- let offset = 0
- while (offset < chunk.value.byteLength) {
- const written = await handle.write(chunk.value, offset, chunk.value.byteLength - offset)
- if (written.bytesWritten === 0) {
- throw new Error('skill-download-write-failed')
- }
- offset += written.bytesWritten
- }
- }
- await handle.sync()
- } catch (error) {
- await reader.cancel().catch(() => undefined)
- if (input.signal?.aborted) {
- throw new SkillInstallOperationError({
- ...SKILL_INSTALL_CANCELLED_FAILURE,
- code: 'skill-download-cancelled'
- })
- }
- throw error
- } finally {
- await handle.close()
+ const response = await fetchWithoutCredentialRedirect(
+ input,
+ allowedOrigins,
+ expiresAt,
+ availability.signal
+ )
+ if (!response.ok || !response.body) {
+ throw new Error('skill-download-transport-failed')
}
- if (compressedBytes !== input.expectedCompressedBytes) {
+ if (response.headers.get('content-type')?.split(';', 1)[0] !== SKILL_PACKAGE_CONTENT_TYPE) {
+ throw new Error('skill-download-content-type-invalid')
+ }
+ const contentLength = response.headers.get('content-length')
+ if (contentLength !== null && Number(contentLength) !== input.expectedCompressedBytes) {
throw new Error('skill-download-size-mismatch')
}
- const archiveSha256 = hash.digest('hex')
- if (!hashesEqual(archiveSha256, input.expectedArchiveSha256)) {
- throw new Error('skill-download-archive-digest-mismatch')
+
+ const processRoot = await prepareTemporaryRoot(input.temporaryRoot)
+ const temporaryDirectory = await mkdtemp(join(processRoot, '.orca-skill-download-'))
+ const archivePath = join(temporaryDirectory, 'package.tar.gz')
+ try {
+ const handle = await open(archivePath, 'wx', 0o600)
+ const reader = response.body.getReader()
+ const hash = createHash('sha256')
+ let compressedBytes = 0
+ try {
+ for (;;) {
+ throwIfSkillDownloadUnavailable({
+ signal: input.signal,
+ now: input.now!,
+ expiresAt
+ })
+ const chunk = await reader.read()
+ if (chunk.done) {
+ break
+ }
+ compressedBytes += chunk.value.byteLength
+ if (
+ compressedBytes > input.expectedCompressedBytes ||
+ compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES
+ ) {
+ throw new Error('skill-download-size-limit')
+ }
+ hash.update(chunk.value)
+ let offset = 0
+ while (offset < chunk.value.byteLength) {
+ const written = await handle.write(chunk.value, offset, chunk.value.byteLength - offset)
+ if (written.bytesWritten === 0) {
+ throw new Error('skill-download-write-failed')
+ }
+ offset += written.bytesWritten
+ }
+ }
+ await handle.sync()
+ } catch (error) {
+ await reader.cancel().catch(() => undefined)
+ if (input.signal?.aborted) {
+ throwIfSkillDownloadUnavailable({
+ signal: input.signal,
+ now: input.now!,
+ expiresAt
+ })
+ }
+ if (isSkillDownloadGrantExpiredAbort(availability.signal)) {
+ throw new Error('skill-download-grant-expired')
+ }
+ throw error
+ } finally {
+ await handle.close()
+ }
+ if (compressedBytes !== input.expectedCompressedBytes) {
+ throw new Error('skill-download-size-mismatch')
+ }
+ const archiveSha256 = hash.digest('hex')
+ if (!hashesEqual(archiveSha256, input.expectedArchiveSha256)) {
+ throw new Error('skill-download-archive-digest-mismatch')
+ }
+ return {
+ archivePath,
+ archiveSha256,
+ compressedBytes,
+ cleanup: () => rm(temporaryDirectory, { recursive: true, force: true })
+ }
+ } catch (error) {
+ await rm(temporaryDirectory, { recursive: true, force: true })
+ throw error
}
- return {
- archivePath,
- archiveSha256,
- compressedBytes,
- cleanup: () => rm(temporaryDirectory, { recursive: true, force: true })
- }
- } catch (error) {
- await rm(temporaryDirectory, { recursive: true, force: true })
- throw error
+ } finally {
+ availability.cleanup()
}
}
diff --git a/src/main/skills/skill-placement-artifact-recovery.ts b/src/main/skills/skill-placement-artifact-recovery.ts
new file mode 100644
index 00000000000..94a40900207
--- /dev/null
+++ b/src/main/skills/skill-placement-artifact-recovery.ts
@@ -0,0 +1,129 @@
+import { lstat, readlink } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+import type { SkillInstalledFileMode, SkillInstallFilesystem } from './skill-install-filesystem'
+import type {
+ SkillPlacementJournalActionV1,
+ SkillPlacementJournalV1
+} from './skill-placement-recovery-journal'
+
+function normalizedPath(path: string): string {
+ const normalized = resolve(path)
+ return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
+}
+
+export async function placementPathExists(path: string): Promise {
+ return Boolean(await lstat(path).catch(() => null))
+}
+
+export async function placementIsAlias(
+ canonicalPath: string,
+ destinationPath: string,
+ filesystem: SkillInstallFilesystem
+): Promise {
+ if (filesystem.aliasTargets) {
+ return filesystem.aliasTargets(canonicalPath, destinationPath).catch(() => false)
+ }
+ return Boolean((await lstat(destinationPath).catch(() => null))?.isSymbolicLink())
+}
+
+export async function placementMatchesDigest(
+ path: string,
+ digest: string,
+ journal: SkillPlacementJournalV1,
+ filesystem: SkillInstallFilesystem,
+ fileModes: readonly SkillInstalledFileMode[] = journal.fileModes
+): Promise {
+ return (
+ (await filesystem.observeSkill(path, fileModes).catch(() => null))?.observedDigest === digest
+ )
+}
+
+export async function placementBackupMatchesPrevious(
+ journal: SkillPlacementJournalV1,
+ action: SkillPlacementJournalActionV1,
+ filesystem: SkillInstallFilesystem
+): Promise {
+ const previous = journal.previousReceipt?.placements.find(
+ (placement) => normalizedPath(placement.path) === normalizedPath(action.destinationPath)
+ )
+ if (!previous || !journal.previousReceipt) {
+ return false
+ }
+ if (previous.topology !== 'provider-alias') {
+ return placementMatchesDigest(
+ action.backupPath,
+ journal.previousReceipt.packageDigest,
+ journal,
+ filesystem,
+ journal.previousReceipt.fileModes ?? []
+ )
+ }
+ if (filesystem.aliasTargets) {
+ return filesystem.aliasTargets(journal.canonicalPath, action.backupPath).catch(() => false)
+ }
+ const target = await readlink(action.backupPath).catch(() => null)
+ return Boolean(
+ target &&
+ normalizedPath(resolve(dirname(action.backupPath), target)) ===
+ normalizedPath(journal.canonicalPath)
+ )
+}
+
+export async function settleDesiredPlacementArtifacts(
+ journal: SkillPlacementJournalV1,
+ action: SkillPlacementJournalActionV1,
+ filesystem: SkillInstallFilesystem
+): Promise {
+ const [destinationExists, stagingExists, backupExists] = await Promise.all([
+ placementPathExists(action.destinationPath),
+ placementPathExists(action.stagingPath),
+ placementPathExists(action.backupPath)
+ ])
+ if (backupExists) {
+ if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) {
+ throw new Error('skill-placement-recovery-conflict')
+ }
+ if (destinationExists) {
+ if (
+ !(await placementMatchesDigest(
+ action.destinationPath,
+ journal.packageDigest,
+ journal,
+ filesystem
+ ))
+ ) {
+ throw new Error('skill-placement-recovery-conflict')
+ }
+ await filesystem.remove(action.backupPath)
+ } else if (
+ stagingExists &&
+ (await placementMatchesDigest(action.stagingPath, journal.packageDigest, journal, filesystem))
+ ) {
+ await filesystem.rename(action.stagingPath, action.destinationPath)
+ await filesystem.remove(action.backupPath)
+ return true
+ } else {
+ if (stagingExists) {
+ await filesystem.remove(action.stagingPath)
+ }
+ await filesystem.rename(action.backupPath, action.destinationPath)
+ }
+ }
+ if (await placementPathExists(action.stagingPath)) {
+ if (
+ !(await placementMatchesDigest(
+ action.stagingPath,
+ journal.packageDigest,
+ journal,
+ filesystem
+ ))
+ ) {
+ await filesystem.remove(action.stagingPath)
+ return true
+ }
+ await ((await placementPathExists(action.destinationPath))
+ ? filesystem.remove(action.stagingPath)
+ : filesystem.rename(action.stagingPath, action.destinationPath))
+ }
+ return stagingExists || backupExists
+}
diff --git a/src/main/skills/skill-placement-copy.ts b/src/main/skills/skill-placement-copy.ts
index 08ad2601537..3e7c17182f4 100644
--- a/src/main/skills/skill-placement-copy.ts
+++ b/src/main/skills/skill-placement-copy.ts
@@ -29,12 +29,20 @@ export async function replaceOwnedSkillPlacementCopy(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem,
- fileModes?: readonly SkillInstalledFileMode[]
+ fileModes?: readonly SkillInstalledFileMode[],
+ transaction?: { replacementPath: string; backupPath: string; retainBackup: boolean }
): Promise {
- const replacement = `${destinationPath}.orca-copy-${randomUUID()}`
- const backup = `${destinationPath}.orca-backup-${randomUUID()}`
+ const replacement = transaction?.replacementPath ?? `${destinationPath}.orca-copy-${randomUUID()}`
+ const backup = transaction?.backupPath ?? `${destinationPath}.orca-backup-${randomUUID()}`
try {
- await createVerifiedSkillPlacementCopy(canonicalPath, replacement, filesystem, fileModes)
+ await (transaction
+ ? createSkillPlacementCopyAtMissingDestination(
+ canonicalPath,
+ replacement,
+ filesystem,
+ fileModes
+ )
+ : createVerifiedSkillPlacementCopy(canonicalPath, replacement, filesystem, fileModes))
await filesystem.rename(destinationPath, backup)
try {
await filesystem.rename(replacement, destinationPath)
@@ -42,7 +50,9 @@ export async function replaceOwnedSkillPlacementCopy(
await filesystem.rename(backup, destinationPath)
throw error
}
- await filesystem.remove(backup)
+ if (!transaction?.retainBackup) {
+ await filesystem.remove(backup)
+ }
} finally {
await filesystem.remove(replacement)
}
@@ -52,14 +62,17 @@ export async function createSkillPlacementCopyAtMissingDestination(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem,
- fileModes?: readonly SkillInstalledFileMode[]
+ fileModes?: readonly SkillInstalledFileMode[],
+ stagingPath?: string
): Promise {
await mkdir(dirname(destinationPath), { recursive: true })
- await mkdir(destinationPath, { mode: 0o700 })
+ const copyPath = stagingPath ?? destinationPath
+ await mkdir(copyPath, { mode: 0o700 })
let complete = false
+ let placed = false
try {
for (const entry of await readdir(canonicalPath)) {
- await cp(join(canonicalPath, entry), join(destinationPath, entry), {
+ await cp(join(canonicalPath, entry), join(copyPath, entry), {
recursive: true,
verbatimSymlinks: true,
force: false,
@@ -68,15 +81,23 @@ export async function createSkillPlacementCopyAtMissingDestination(
}
const [source, copied] = await Promise.all([
filesystem.observeSkill(canonicalPath, fileModes),
- filesystem.observeSkill(destinationPath, fileModes)
+ filesystem.observeSkill(copyPath, fileModes)
])
if (source.observedDigest !== copied.observedDigest) {
throw new Error('skill-placement-copy-digest-mismatch')
}
+ if (stagingPath) {
+ await filesystem.rename(stagingPath, destinationPath)
+ placed = true
+ await filesystem.observeSkill(destinationPath, fileModes)
+ }
complete = true
} finally {
if (!complete) {
- await filesystem.remove(destinationPath)
+ await filesystem.remove(copyPath)
+ if (placed) {
+ await filesystem.remove(destinationPath)
+ }
}
}
}
diff --git a/src/main/skills/skill-placement-reconciliation.test.ts b/src/main/skills/skill-placement-reconciliation.test.ts
index 0c9d96f3f9d..25bc43bd0d7 100644
--- a/src/main/skills/skill-placement-reconciliation.test.ts
+++ b/src/main/skills/skill-placement-reconciliation.test.ts
@@ -12,10 +12,29 @@ vi.mock('node:fs/promises', async (importOriginal) => ({
}))
import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
+import type { SkillInstallReceiptV1 } from './skill-install-provenance'
import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
const temporaryDirectories: string[] = []
+function receipt(canonicalPath: string, packageDigest: string): SkillInstallReceiptV1 {
+ return {
+ schemaVersion: 1,
+ packageId: 'package_1',
+ versionId: 'version_1',
+ packageDigest,
+ archiveSha256: 'a'.repeat(64),
+ scope: 'global',
+ destinationIdentity: 'global:test',
+ canonicalPath,
+ placements: [],
+ installedAt: new Date().toISOString(),
+ hostIdentity: 'test',
+ fileModes: [],
+ providers: []
+ }
+}
+
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
@@ -143,4 +162,77 @@ describe('skill provider placement reconciliation', () => {
expect(result).toMatchObject({ topology: 'provider-alias', status: 'unchanged' })
expect(createAlias).not.toHaveBeenCalled()
})
+
+ it('updates an owned copy even when host alias inspection returns false', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skill-placement-test-'))
+ temporaryDirectories.push(root)
+ const canonicalPath = join(root, 'canonical', 'private-skill')
+ const providerRoot = join(root, 'provider')
+ const destinationPath = join(providerRoot, 'private-skill')
+ await mkdir(canonicalPath, { recursive: true })
+ await mkdir(destinationPath, { recursive: true })
+ await writeFile(join(canonicalPath, 'SKILL.md'), 'new skill')
+ await writeFile(join(destinationPath, 'SKILL.md'), 'old skill')
+ const previousDigest = (await nativeSkillInstallFilesystem.observeSkill(destinationPath))
+ .observedDigest
+ const nextDigest = (await nativeSkillInstallFilesystem.observeSkill(canonicalPath))
+ .observedDigest
+ const previous = receipt(canonicalPath, previousDigest)
+ previous.placements = [
+ {
+ provider: 'claude',
+ path: destinationPath,
+ topology: 'independent-copy',
+ status: 'installed'
+ }
+ ]
+
+ const result = await reconcileSkillProviderPlacement({
+ canonicalPath,
+ skillName: 'private-skill',
+ destination: { provider: 'claude', rootPath: providerRoot, readsCanonicalRoot: false },
+ previousReceipt: previous,
+ packageDigest: nextDigest,
+ filesystem: { ...nativeSkillInstallFilesystem, aliasTargets: async () => false }
+ })
+
+ expect(result).toMatchObject({ topology: 'independent-copy', status: 'installed' })
+ expect(await readFile(join(destinationPath, 'SKILL.md'), 'utf8')).toBe('new skill')
+ })
+
+ it('does not update a byte-identical copy previously recorded as skipped', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skill-placement-test-'))
+ temporaryDirectories.push(root)
+ const canonicalPath = join(root, 'canonical', 'private-skill')
+ const providerRoot = join(root, 'provider')
+ const destinationPath = join(providerRoot, 'private-skill')
+ await mkdir(canonicalPath, { recursive: true })
+ await mkdir(destinationPath, { recursive: true })
+ await writeFile(join(canonicalPath, 'SKILL.md'), 'new skill')
+ await writeFile(join(destinationPath, 'SKILL.md'), 'old skill')
+ const previousDigest = (await nativeSkillInstallFilesystem.observeSkill(destinationPath))
+ .observedDigest
+ const nextDigest = (await nativeSkillInstallFilesystem.observeSkill(canonicalPath))
+ .observedDigest
+ const previous = receipt(canonicalPath, previousDigest)
+ previous.placements = [
+ {
+ provider: 'claude',
+ path: destinationPath,
+ topology: 'independent-copy',
+ status: 'skipped'
+ }
+ ]
+
+ const result = await reconcileSkillProviderPlacement({
+ canonicalPath,
+ skillName: 'private-skill',
+ destination: { provider: 'claude', rootPath: providerRoot, readsCanonicalRoot: false },
+ previousReceipt: previous,
+ packageDigest: nextDigest
+ })
+
+ expect(result).toMatchObject({ status: 'skipped', errorCategory: 'skill-placement-unowned' })
+ expect(await readFile(join(destinationPath, 'SKILL.md'), 'utf8')).toBe('old skill')
+ })
})
diff --git a/src/main/skills/skill-placement-reconciliation.ts b/src/main/skills/skill-placement-reconciliation.ts
index 746f7d646df..7116a01411c 100644
--- a/src/main/skills/skill-placement-reconciliation.ts
+++ b/src/main/skills/skill-placement-reconciliation.ts
@@ -40,7 +40,10 @@ function previousPlacement(
return (
receipt?.placements.find(
(placement) =>
- placement.provider === provider && normalizedPath(placement.path) === normalizedPath(path)
+ placement.provider === provider &&
+ placement.status !== 'failed' &&
+ placement.status !== 'skipped' &&
+ normalizedPath(placement.path) === normalizedPath(path)
) ?? null
)
}
@@ -74,12 +77,54 @@ async function reconcileExistingPlacement(input: {
filesystem: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
observation: PlacementObservation
+ transaction?: { stagingPath: string; backupPath: string }
}): Promise {
const previous = previousPlacement(
input.previousReceipt,
input.destination.provider,
input.destinationPath
)
+ if (previous?.topology === 'independent-copy') {
+ const observed = await input.filesystem
+ .observeSkill(input.destinationPath, input.previousReceipt?.fileModes)
+ .catch(() => null)
+ if (!observed || observed.observedDigest !== input.previousReceipt?.packageDigest) {
+ return {
+ provider: input.destination.provider,
+ path: input.destinationPath,
+ topology: 'independent-copy',
+ status: 'skipped',
+ ...placementFailure('skill-placement-modified-copy')
+ }
+ }
+ if (observed.observedDigest === input.packageDigest) {
+ return {
+ provider: input.destination.provider,
+ path: input.destinationPath,
+ topology: 'independent-copy',
+ status: 'unchanged'
+ }
+ }
+ await replaceOwnedSkillPlacementCopy(
+ input.canonicalPath,
+ input.destinationPath,
+ input.filesystem,
+ input.fileModes,
+ input.transaction
+ ? {
+ replacementPath: input.transaction.stagingPath,
+ backupPath: input.transaction.backupPath,
+ retainBackup: true
+ }
+ : undefined
+ )
+ return {
+ provider: input.destination.provider,
+ path: input.destinationPath,
+ topology: 'independent-copy',
+ status: 'installed'
+ }
+ }
if (input.filesystem.aliasTargets) {
return (await input.filesystem.aliasTargets(input.canonicalPath, input.destinationPath))
? {
@@ -130,40 +175,6 @@ async function reconcileExistingPlacement(input: {
...placementFailure('skill-placement-unowned-link')
}
}
- if (stat.isDirectory() && previous?.topology === 'independent-copy') {
- const observed = await input.filesystem
- .observeSkill(input.destinationPath, input.previousReceipt?.fileModes)
- .catch(() => null)
- if (!observed || observed.observedDigest !== input.previousReceipt?.packageDigest) {
- return {
- provider: input.destination.provider,
- path: input.destinationPath,
- topology: 'independent-copy',
- status: 'skipped',
- ...placementFailure('skill-placement-modified-copy')
- }
- }
- if (observed.observedDigest === input.packageDigest) {
- return {
- provider: input.destination.provider,
- path: input.destinationPath,
- topology: 'independent-copy',
- status: 'unchanged'
- }
- }
- await replaceOwnedSkillPlacementCopy(
- input.canonicalPath,
- input.destinationPath,
- input.filesystem,
- input.fileModes
- )
- return {
- provider: input.destination.provider,
- path: input.destinationPath,
- topology: 'independent-copy',
- status: 'installed'
- }
- }
return {
provider: input.destination.provider,
path: input.destinationPath,
@@ -180,6 +191,7 @@ async function createMissingPlacement(input: {
filesystem: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
observation: PlacementObservation
+ transaction?: { stagingPath: string; backupPath: string }
}): Promise {
try {
await createProviderAlias(input.canonicalPath, input.destinationPath, input.filesystem)
@@ -196,7 +208,8 @@ async function createMissingPlacement(input: {
input.canonicalPath,
input.destinationPath,
input.filesystem,
- input.fileModes
+ input.fileModes,
+ input.transaction?.stagingPath
)
return {
provider: input.destination.provider,
@@ -225,6 +238,7 @@ export async function reconcileSkillProviderPlacement(input: {
filesystem?: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
targetPlatform?: 'darwin' | 'linux' | 'win32' | 'other'
+ transaction?: { stagingPath: string; backupPath: string }
}): Promise {
if (input.destination.readsCanonicalRoot) {
return null
diff --git a/src/main/skills/skill-placement-recovery-journal.ts b/src/main/skills/skill-placement-recovery-journal.ts
new file mode 100644
index 00000000000..fb83973218f
--- /dev/null
+++ b/src/main/skills/skill-placement-recovery-journal.ts
@@ -0,0 +1,219 @@
+import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
+import { isSkillInstallProviderId } from '../../shared/skill-install-providers'
+import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
+import type { SkillInstalledFileMode } from './skill-install-filesystem'
+import { skillInstallStateKey, type SkillInstallReceiptV1 } from './skill-install-provenance'
+import {
+ resolveSkillProviderDestinations,
+ type SkillProviderRootOverrides
+} from './skill-provider-destinations'
+
+const JOURNAL_MAX_BYTES = 4 * 1024 * 1024
+
+export type SkillPlacementJournalActionV1 = {
+ provider: string
+ destinationPath: string
+ rootPath: string
+ desired: boolean
+ stagingPath: string
+ backupPath: string
+}
+
+export type SkillPlacementJournalV1 = {
+ schemaVersion: 1
+ operation: 'place'
+ canonicalPath: string
+ packageDigest: string
+ fileModes: SkillInstalledFileMode[]
+ receipt: SkillInstallReceiptV1
+ previousReceipt: SkillInstallReceiptV1 | null
+ providers: string[]
+ providerRootOverrides?: SkillProviderRootOverrides
+ actions: SkillPlacementJournalActionV1[]
+ wslDistro?: string
+}
+
+export function skillPlacementJournalPath(stateDirectory: string, canonicalPath: string): string {
+ return join(stateDirectory, 'placement-journals', `${skillInstallStateKey(canonicalPath)}.json`)
+}
+
+function normalizedPath(path: string): string {
+ const normalized = resolve(path)
+ return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
+}
+
+function scopeRoot(canonicalPath: string): string | null {
+ const skills = dirname(canonicalPath)
+ const agents = dirname(skills)
+ return basename(skills) === 'skills' && basename(agents) === '.agents' ? dirname(agents) : null
+}
+
+function validFileModes(value: unknown): value is SkillInstalledFileMode[] {
+ if (!Array.isArray(value)) {
+ return false
+ }
+ const paths = value.map((entry) =>
+ entry && typeof entry === 'object' && 'path' in entry ? (entry as { path: unknown }).path : null
+ )
+ return (
+ paths.every(
+ (path) =>
+ typeof path === 'string' &&
+ path.length > 0 &&
+ path.length <= 4096 &&
+ !path.includes('\0') &&
+ !path.startsWith('/') &&
+ !path.split(/[\\/]/u).includes('..')
+ ) &&
+ new Set(paths).size === paths.length &&
+ value.every(
+ (entry) =>
+ entry &&
+ typeof entry === 'object' &&
+ typeof (entry as { executable?: unknown }).executable === 'boolean'
+ )
+ )
+}
+
+function validProviderRootOverrides(value: unknown): value is SkillProviderRootOverrides {
+ if (value === undefined) {
+ return true
+ }
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false
+ }
+ return Object.entries(value).every(
+ ([provider, root]) =>
+ (provider === 'claude' || provider === 'grok') &&
+ typeof root === 'string' &&
+ root.length > 0 &&
+ root.length <= 32_768 &&
+ !root.includes('\0') &&
+ isAbsolute(root)
+ )
+}
+
+function isJournal(value: unknown, canonicalPath: string): value is SkillPlacementJournalV1 {
+ if (!value || typeof value !== 'object') {
+ return false
+ }
+ const journal = value as Partial
+ if (
+ journal.schemaVersion !== 1 ||
+ journal.operation !== 'place' ||
+ journal.canonicalPath !== canonicalPath ||
+ !journal.receipt ||
+ journal.receipt.canonicalPath !== canonicalPath ||
+ journal.packageDigest !== journal.receipt.packageDigest ||
+ !/^[a-f0-9]{64}$/u.test(journal.packageDigest ?? '') ||
+ typeof journal.receipt.packageId !== 'string' ||
+ typeof journal.receipt.versionId !== 'string' ||
+ (journal.receipt.scope !== 'global' && journal.receipt.scope !== 'workspace') ||
+ !Array.isArray(journal.receipt.placements) ||
+ !validFileModes(journal.fileModes) ||
+ !Array.isArray(journal.providers) ||
+ !journal.providers.every(isSkillInstallProviderId) ||
+ new Set(journal.providers).size !== journal.providers.length ||
+ !validProviderRootOverrides(journal.providerRootOverrides) ||
+ (journal.providerRootOverrides !== undefined && journal.receipt.scope !== 'global') ||
+ !Array.isArray(journal.actions) ||
+ !journal.actions.every(
+ (action) =>
+ action &&
+ typeof action.provider === 'string' &&
+ typeof action.destinationPath === 'string' &&
+ typeof action.rootPath === 'string' &&
+ typeof action.desired === 'boolean' &&
+ typeof action.stagingPath === 'string' &&
+ typeof action.backupPath === 'string'
+ ) ||
+ (journal.previousReceipt !== null &&
+ journal.previousReceipt?.canonicalPath !== canonicalPath) ||
+ (journal.wslDistro !== undefined && typeof journal.wslDistro !== 'string')
+ ) {
+ return false
+ }
+ const root = scopeRoot(canonicalPath)
+ if (!root) {
+ return false
+ }
+ const destinations = resolveSkillProviderDestinations({
+ scope: journal.receipt.scope,
+ homeDirectory: root,
+ ...(journal.receipt.scope === 'workspace' ? { workspaceDirectory: root } : {}),
+ detectedProviders: journal.providers,
+ providerRootOverrides: journal.providerRootOverrides
+ }).filter((destination) => !destination.readsCanonicalRoot)
+ const desiredByProvider = new Map(
+ destinations.map((destination) => [destination.provider, destination])
+ )
+ const normalizedActionPaths = journal.actions.flatMap((action) => [
+ normalizedPath(action.destinationPath),
+ normalizedPath(action.stagingPath),
+ normalizedPath(action.backupPath)
+ ])
+ return (
+ new Set(normalizedActionPaths).size === normalizedActionPaths.length &&
+ journal.actions.every((action) => {
+ const desiredDestination = desiredByProvider.get(action.provider as never)
+ const previousPlacement = journal.previousReceipt?.placements.find(
+ (placement) =>
+ placement.provider === action.provider &&
+ normalizedPath(placement.path) === normalizedPath(action.destinationPath)
+ )
+ const destination = action.desired
+ ? desiredDestination
+ : previousPlacement
+ ? {
+ provider: action.provider,
+ rootPath: dirname(previousPlacement.path),
+ readsCanonicalRoot: false
+ }
+ : undefined
+ const name = basename(canonicalPath)
+ return Boolean(
+ destination &&
+ action.rootPath === destination.rootPath &&
+ (action.desired
+ ? Boolean(desiredDestination)
+ : Boolean(
+ previousPlacement &&
+ (!desiredDestination ||
+ normalizedPath(desiredDestination.rootPath) !== normalizedPath(action.rootPath))
+ )) &&
+ normalizedPath(action.destinationPath) ===
+ normalizedPath(join(destination.rootPath, name)) &&
+ dirname(action.stagingPath) === dirname(action.destinationPath) &&
+ dirname(action.backupPath) === dirname(action.destinationPath) &&
+ basename(action.stagingPath).startsWith(`.${name}.orca-placement-staging-`) &&
+ basename(action.backupPath).startsWith(`.${name}.orca-placement-backup-`) &&
+ action.stagingPath !== action.backupPath
+ )
+ })
+ )
+}
+
+export async function readSkillPlacementRecoveryJournal(
+ stateDirectory: string,
+ canonicalPath: string
+): Promise {
+ try {
+ const value: unknown = JSON.parse(
+ (
+ await readNodeFileWithinLimit(
+ skillPlacementJournalPath(stateDirectory, canonicalPath),
+ JOURNAL_MAX_BYTES
+ )
+ ).buffer.toString('utf8')
+ )
+ if (!isJournal(value, canonicalPath)) {
+ throw new Error('skill-placement-journal-invalid')
+ }
+ return value
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return null
+ }
+ throw error
+ }
+}
diff --git a/src/main/skills/skill-placement-transaction-controller.ts b/src/main/skills/skill-placement-transaction-controller.ts
new file mode 100644
index 00000000000..6bd83938290
--- /dev/null
+++ b/src/main/skills/skill-placement-transaction-controller.ts
@@ -0,0 +1,150 @@
+import { randomUUID } from 'node:crypto'
+import { basename, dirname, join, resolve } from 'node:path'
+import { isSkillInstallProviderId } from '../../shared/skill-install-providers'
+import {
+ nativeSkillInstallFilesystem,
+ type SkillInstallFilesystem
+} from './skill-install-filesystem'
+import { writeSkillStateFile, type SkillInstallReceiptV1 } from './skill-install-provenance'
+import {
+ skillPlacementJournalPath,
+ type SkillPlacementJournalV1
+} from './skill-placement-recovery-journal'
+import {
+ finishSkillPlacementTransaction,
+ recoverSkillPlacementTransaction
+} from './skill-placement-transaction'
+import {
+ resolveSkillProviderDestinations,
+ type SkillProviderDestination,
+ type SkillProviderRootOverrides
+} from './skill-provider-destinations'
+
+function normalizedPath(path: string): string {
+ const normalized = resolve(path)
+ return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
+}
+
+export function createSkillPlacementTransaction(input: {
+ stateDirectory: string
+ scope: 'global' | 'workspace'
+ homeDirectory: string
+ workspaceDirectory?: string
+ detectedProviders: readonly string[]
+ providerRootOverrides?: SkillProviderRootOverrides
+ filesystem?: SkillInstallFilesystem
+ wslDistro?: string
+ signal?: AbortSignal
+}): {
+ prepare(previous: SkillInstallReceiptV1 | null, receipt: SkillInstallReceiptV1): Promise
+ commit(receipt: SkillInstallReceiptV1): Promise
+ finish(receipt: SkillInstallReceiptV1): Promise
+} {
+ const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
+ const providerRootOverrides =
+ input.scope === 'global' &&
+ input.providerRootOverrides &&
+ Object.keys(input.providerRootOverrides).length > 0
+ ? input.providerRootOverrides
+ : undefined
+ let canonicalPath: string | null = null
+ return {
+ async prepare(previous, receipt) {
+ canonicalPath = receipt.canonicalPath
+ const selected = new Set(input.detectedProviders.filter(isSkillInstallProviderId))
+ const destinations = resolveSkillProviderDestinations({
+ scope: input.scope,
+ homeDirectory: input.homeDirectory,
+ workspaceDirectory: input.workspaceDirectory,
+ detectedProviders: [...selected],
+ providerRootOverrides
+ }).filter((destination) => !destination.readsCanonicalRoot)
+ const desiredPathByProvider = new Map(
+ destinations.map((destination) => [
+ destination.provider,
+ normalizedPath(destination.rootPath)
+ ])
+ )
+ for (const placement of previous?.placements ?? []) {
+ if (
+ !isSkillInstallProviderId(placement.provider) ||
+ placement.topology === 'canonical-copy' ||
+ placement.status === 'failed' ||
+ placement.status === 'skipped'
+ ) {
+ continue
+ }
+ const claimedBy = destinations.find(
+ (destination) =>
+ normalizedPath(destination.rootPath) === normalizedPath(dirname(placement.path))
+ )
+ if (claimedBy && claimedBy.provider !== placement.provider) {
+ throw new Error('skill-install-provider-root-ownership-conflict')
+ }
+ }
+ const previousDestinationKeys = new Set()
+ const previousDestinations: SkillProviderDestination[] =
+ previous?.placements.flatMap((placement) => {
+ if (!isSkillInstallProviderId(placement.provider)) {
+ return []
+ }
+ if (placement.status === 'failed' || placement.status === 'skipped') {
+ return []
+ }
+ const rootPath = dirname(placement.path)
+ const key = `${placement.provider}:${normalizedPath(rootPath)}`
+ if (
+ placement.topology === 'canonical-copy' ||
+ desiredPathByProvider.get(placement.provider) === normalizedPath(rootPath) ||
+ previousDestinationKeys.has(key)
+ ) {
+ return []
+ }
+ previousDestinationKeys.add(key)
+ return [{ provider: placement.provider, readsCanonicalRoot: false, rootPath }]
+ }) ?? []
+ const id = randomUUID()
+ const name = basename(receipt.canonicalPath)
+ const journal: SkillPlacementJournalV1 = {
+ schemaVersion: 1,
+ operation: 'place',
+ canonicalPath: receipt.canonicalPath,
+ packageDigest: receipt.packageDigest,
+ fileModes: receipt.fileModes ?? [],
+ receipt: { ...receipt, providers: [...selected] },
+ previousReceipt: previous,
+ providers: [...selected],
+ actions: [...destinations, ...previousDestinations].map((destination) => ({
+ provider: destination.provider,
+ destinationPath: join(destination.rootPath, name),
+ rootPath: destination.rootPath,
+ desired: destinations.includes(destination),
+ stagingPath: join(destination.rootPath, `.${name}.orca-placement-staging-${id}`),
+ backupPath: join(destination.rootPath, `.${name}.orca-placement-backup-${id}`)
+ })),
+ ...(providerRootOverrides ? { providerRootOverrides } : {}),
+ ...(input.wslDistro ? { wslDistro: input.wslDistro } : {})
+ }
+ await writeSkillStateFile(
+ skillPlacementJournalPath(input.stateDirectory, receipt.canonicalPath),
+ journal
+ )
+ },
+ async commit(receipt) {
+ if (canonicalPath !== receipt.canonicalPath) {
+ throw new Error('skill-placement-transaction-not-prepared')
+ }
+ return (
+ (await recoverSkillPlacementTransaction(
+ input.stateDirectory,
+ receipt.canonicalPath,
+ filesystem,
+ { finalize: false, signal: input.signal }
+ )) ?? receipt
+ )
+ },
+ async finish(receipt) {
+ await finishSkillPlacementTransaction(input.stateDirectory, receipt.canonicalPath, filesystem)
+ }
+ }
+}
diff --git a/src/main/skills/skill-placement-transaction.test.ts b/src/main/skills/skill-placement-transaction.test.ts
new file mode 100644
index 00000000000..33054469ea7
--- /dev/null
+++ b/src/main/skills/skill-placement-transaction.test.ts
@@ -0,0 +1,328 @@
+import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { SkillInstallReceiptV1 } from './skill-install-provenance'
+import { readSkillInstallReceipt } from './skill-install-provenance'
+import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
+import { createSkillPlacementTransaction } from './skill-placement-transaction-controller'
+import {
+ readSkillPlacementRecoveryJournal,
+ skillPlacementJournalPath
+} from './skill-placement-recovery-journal'
+import { recoverSkillPlacementTransaction } from './skill-placement-transaction'
+
+const roots: string[] = []
+
+async function skill(root: string, body: string): Promise<{ path: string; digest: string }> {
+ await mkdir(root, { recursive: true })
+ await writeFile(join(root, 'SKILL.md'), body)
+ return {
+ path: root,
+ digest: (await nativeSkillInstallFilesystem.observeSkill(root)).observedDigest
+ }
+}
+
+function receipt(
+ canonicalPath: string,
+ packageDigest: string,
+ versionId: string,
+ placements: SkillInstallReceiptV1['placements']
+): SkillInstallReceiptV1 {
+ return {
+ schemaVersion: 1,
+ packageId: 'package_1',
+ versionId,
+ packageDigest,
+ archiveSha256: 'a'.repeat(64),
+ scope: 'global',
+ destinationIdentity: 'global:test',
+ canonicalPath,
+ placements,
+ installedAt: new Date().toISOString(),
+ hostIdentity: 'test',
+ fileModes: []
+ }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('skill placement transaction recovery', () => {
+ it('does not persist empty global root overrides for a workspace transaction', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-placement-workspace-root-'))
+ roots.push(root)
+ const workspace = join(root, 'workspace')
+ const canonical = await skill(join(workspace, '.agents', 'skills', 'alpha'), '# Skill')
+ const stateDirectory = join(root, 'state')
+ const transaction = createSkillPlacementTransaction({
+ stateDirectory,
+ scope: 'workspace',
+ homeDirectory: join(root, 'home'),
+ workspaceDirectory: workspace,
+ detectedProviders: ['claude'],
+ providerRootOverrides: {}
+ })
+ await transaction.prepare(null, receipt(canonical.path, canonical.digest, 'version_1', []))
+
+ const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
+ expect(journal).not.toBeNull()
+ expect(journal).not.toHaveProperty('providerRootOverrides')
+ })
+
+ it('rebuilds a partial staged copy and publishes its placement receipt', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-placement-partial-'))
+ roots.push(root)
+ const canonical = await skill(join(root, 'home', '.agents', 'skills', 'alpha'), '# New')
+ const stateDirectory = join(root, 'state')
+ const next = receipt(canonical.path, canonical.digest, 'version_1', [])
+ const filesystem = {
+ ...nativeSkillInstallFilesystem,
+ createAlias: async () => {
+ throw new Error('aliases-disabled')
+ }
+ }
+ const transaction = createSkillPlacementTransaction({
+ stateDirectory,
+ scope: 'global',
+ homeDirectory: join(root, 'home'),
+ detectedProviders: ['claude'],
+ filesystem
+ })
+ await transaction.prepare(null, next)
+ const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
+ if (!journal?.actions[0]) {
+ throw new Error('missing placement action')
+ }
+ await mkdir(journal.actions[0].stagingPath, { recursive: true })
+ await writeFile(join(journal.actions[0].stagingPath, 'partial'), 'partial')
+
+ const recovered = await recoverSkillPlacementTransaction(
+ stateDirectory,
+ canonical.path,
+ filesystem
+ )
+
+ expect(recovered?.placements).toEqual([
+ expect.objectContaining({ provider: 'agent-skills' }),
+ expect.objectContaining({ provider: 'claude', topology: 'independent-copy' })
+ ])
+ expect(
+ await readFile(join(root, 'home', '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8')
+ ).toBe('# New')
+ await expect(
+ lstat(skillPlacementJournalPath(stateDirectory, canonical.path))
+ ).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('rolls a copy replacement forward after the old placement moved to backup', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-placement-replace-'))
+ roots.push(root)
+ const home = join(root, 'home')
+ const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Old')
+ const providerPath = join(home, '.claude', 'skills', 'alpha')
+ await cp(canonical.path, providerPath, { recursive: true })
+ const previous = receipt(canonical.path, canonical.digest, 'version_1', [
+ {
+ provider: 'agent-skills',
+ path: canonical.path,
+ topology: 'canonical-copy',
+ status: 'installed'
+ },
+ {
+ provider: 'claude',
+ path: providerPath,
+ topology: 'independent-copy',
+ status: 'installed'
+ }
+ ])
+ await writeFile(join(canonical.path, 'SKILL.md'), '# New')
+ const digest = (await nativeSkillInstallFilesystem.observeSkill(canonical.path)).observedDigest
+ const next = receipt(canonical.path, digest, 'version_2', [])
+ const stateDirectory = join(root, 'state')
+ const transaction = createSkillPlacementTransaction({
+ stateDirectory,
+ scope: 'global',
+ homeDirectory: home,
+ detectedProviders: ['claude']
+ })
+ await transaction.prepare(previous, next)
+ const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
+ if (!journal?.actions[0]) {
+ throw new Error('missing placement action')
+ }
+ await cp(canonical.path, journal.actions[0].stagingPath, { recursive: true })
+ await rename(providerPath, journal.actions[0].backupPath)
+
+ const recovered = await recoverSkillPlacementTransaction(stateDirectory, canonical.path)
+
+ expect(recovered?.placements).toContainEqual(
+ expect.objectContaining({ provider: 'claude', topology: 'independent-copy' })
+ )
+ expect(await readFile(join(providerPath, 'SKILL.md'), 'utf8')).toBe('# New')
+ expect((await readSkillInstallReceipt(stateDirectory, canonical.path))?.versionId).toBe(
+ 'version_2'
+ )
+ await expect(lstat(journal.actions[0].backupPath)).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('restores the previous copy when placement and rollback renames both fail', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-placement-rollback-'))
+ roots.push(root)
+ const home = join(root, 'home')
+ const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Old')
+ const providerPath = join(home, '.claude', 'skills', 'alpha')
+ await cp(canonical.path, providerPath, { recursive: true })
+ const previous = receipt(canonical.path, canonical.digest, 'version_1', [
+ {
+ provider: 'claude',
+ path: providerPath,
+ topology: 'independent-copy',
+ status: 'installed'
+ }
+ ])
+ await writeFile(join(canonical.path, 'SKILL.md'), '# New')
+ const digest = (await nativeSkillInstallFilesystem.observeSkill(canonical.path)).observedDigest
+ const stateDirectory = join(root, 'state')
+ let failRenames = true
+ const filesystem = {
+ ...nativeSkillInstallFilesystem,
+ createAlias: async () => {
+ throw new Error('aliases-disabled')
+ },
+ rename: async (source: string, target: string): Promise => {
+ if (failRenames && source.includes('.orca-placement-staging-')) {
+ throw new Error('injected-placement-rename-failure')
+ }
+ if (failRenames && source.includes('.orca-placement-backup-')) {
+ throw new Error('injected-rollback-rename-failure')
+ }
+ await nativeSkillInstallFilesystem.rename(source, target)
+ }
+ }
+ const transaction = createSkillPlacementTransaction({
+ stateDirectory,
+ scope: 'global',
+ homeDirectory: home,
+ detectedProviders: ['claude'],
+ filesystem
+ })
+ await transaction.prepare(previous, receipt(canonical.path, digest, 'version_2', []))
+
+ const failed = await transaction.commit(receipt(canonical.path, digest, 'version_2', []))
+ expect(failed.placements).toContainEqual(
+ expect.objectContaining({ provider: 'claude', status: 'failed' })
+ )
+ failRenames = false
+ await transaction.finish(failed)
+
+ expect(await readFile(join(providerPath, 'SKILL.md'), 'utf8')).toBe('# Old')
+ })
+
+ it('moves an owned placement when Claude changes to a custom config root', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-placement-custom-root-'))
+ roots.push(root)
+ const home = join(root, 'home')
+ const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill')
+ const previousPath = join(home, '.claude', 'skills', 'alpha')
+ await cp(canonical.path, previousPath, { recursive: true })
+ const previous = receipt(canonical.path, canonical.digest, 'version_1', [
+ {
+ provider: 'claude',
+ path: previousPath,
+ topology: 'independent-copy',
+ status: 'installed'
+ }
+ ])
+ const next = receipt(canonical.path, canonical.digest, 'version_2', [])
+ const stateDirectory = join(root, 'state')
+ const customRoot = join(root, 'managed-claude', 'skills')
+ const transaction = createSkillPlacementTransaction({
+ stateDirectory,
+ scope: 'global',
+ homeDirectory: home,
+ detectedProviders: ['claude'],
+ providerRootOverrides: { claude: customRoot }
+ })
+ await transaction.prepare(previous, next)
+
+ const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
+ expect(journal?.actions).toEqual([
+ expect.objectContaining({ rootPath: customRoot, desired: true }),
+ expect.objectContaining({ rootPath: join(home, '.claude', 'skills'), desired: false })
+ ])
+
+ const committed = await transaction.commit(next)
+ await transaction.finish(committed)
+
+ await expect(lstat(previousPath)).rejects.toMatchObject({ code: 'ENOENT' })
+ expect(await readFile(join(customRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Skill')
+ })
+
+ it('rejects a historical root claimed by another selected provider before journaling', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-placement-cross-provider-root-'))
+ roots.push(root)
+ const home = join(root, 'home')
+ const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill')
+ const sharedRoot = join(root, 'shared', 'skills')
+ const previousPath = join(sharedRoot, 'alpha')
+ await cp(canonical.path, previousPath, { recursive: true })
+ const previous = receipt(canonical.path, canonical.digest, 'version_1', [
+ {
+ provider: 'claude',
+ path: previousPath,
+ topology: 'independent-copy',
+ status: 'installed'
+ }
+ ])
+ const next = receipt(canonical.path, canonical.digest, 'version_2', [])
+ const stateDirectory = join(root, 'state')
+ const transaction = createSkillPlacementTransaction({
+ stateDirectory,
+ scope: 'global',
+ homeDirectory: home,
+ detectedProviders: ['claude', 'grok'],
+ providerRootOverrides: {
+ claude: join(root, 'new-claude', 'skills'),
+ grok: sharedRoot
+ }
+ })
+ await expect(transaction.prepare(previous, next)).rejects.toThrow(
+ 'skill-install-provider-root-ownership-conflict'
+ )
+ await expect(
+ lstat(skillPlacementJournalPath(stateDirectory, canonical.path))
+ ).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('does not treat a skipped historical placement as ownership', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-placement-skipped-owner-'))
+ roots.push(root)
+ const home = join(root, 'home')
+ const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill')
+ const sharedRoot = join(root, 'shared', 'skills')
+ const previous = receipt(canonical.path, canonical.digest, 'version_1', [
+ {
+ provider: 'claude',
+ path: join(sharedRoot, 'alpha'),
+ topology: 'independent-copy',
+ status: 'skipped'
+ }
+ ])
+ const next = receipt(canonical.path, canonical.digest, 'version_2', [])
+ const stateDirectory = join(root, 'state')
+ const transaction = createSkillPlacementTransaction({
+ stateDirectory,
+ scope: 'global',
+ homeDirectory: home,
+ detectedProviders: ['grok'],
+ providerRootOverrides: { grok: sharedRoot }
+ })
+
+ await expect(transaction.prepare(previous, next)).resolves.toBeUndefined()
+ await expect(
+ readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
+ ).resolves.not.toBeNull()
+ })
+})
diff --git a/src/main/skills/skill-placement-transaction.ts b/src/main/skills/skill-placement-transaction.ts
new file mode 100644
index 00000000000..cbd3ff9568f
--- /dev/null
+++ b/src/main/skills/skill-placement-transaction.ts
@@ -0,0 +1,235 @@
+import { rm } from 'node:fs/promises'
+import { basename, resolve } from 'node:path'
+import type { SkillPlacementResult } from '../../shared/skill-install-contract'
+import {
+ nativeSkillInstallFilesystem,
+ type SkillInstallFilesystem
+} from './skill-install-filesystem'
+import {
+ writeSkillInstallReceipt,
+ writeSkillStateFile,
+ type SkillInstallReceiptV1
+} from './skill-install-provenance'
+import { isRemovableSkillPlacement } from './skill-removable-placement'
+import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
+import {
+ readSkillPlacementRecoveryJournal,
+ skillPlacementJournalPath,
+ type SkillPlacementJournalActionV1,
+ type SkillPlacementJournalV1
+} from './skill-placement-recovery-journal'
+import {
+ placementBackupMatchesPrevious,
+ placementIsAlias,
+ placementMatchesDigest,
+ placementPathExists,
+ settleDesiredPlacementArtifacts
+} from './skill-placement-artifact-recovery'
+
+function normalizedPath(path: string): string {
+ const normalized = resolve(path)
+ return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
+}
+
+async function settleDeselectedAction(
+ journal: SkillPlacementJournalV1,
+ action: SkillPlacementJournalActionV1,
+ filesystem: SkillInstallFilesystem,
+ allowedProviderRoots: readonly string[]
+): Promise {
+ if (await placementPathExists(action.backupPath)) {
+ if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) {
+ throw new Error('skill-placement-recovery-conflict')
+ }
+ await filesystem.remove(action.backupPath)
+ }
+ const previous = journal.previousReceipt?.placements.find(
+ (placement) => normalizedPath(placement.path) === normalizedPath(action.destinationPath)
+ )
+ if (!previous || !(await placementPathExists(action.destinationPath))) {
+ return null
+ }
+ const removable = await isRemovableSkillPlacement({
+ placement: previous,
+ receipt: journal.previousReceipt!,
+ allowedProviderRoots,
+ filesystem
+ })
+ if (removable) {
+ await filesystem.rename(action.destinationPath, action.backupPath)
+ return null
+ }
+ return {
+ ...previous,
+ status: 'skipped',
+ errorCategory: 'skill-removal-placement-modified-or-unowned',
+ failure: {
+ category: 'conflict',
+ code: 'skill-removal-placement-modified-or-unowned',
+ retryable: false
+ }
+ }
+}
+
+export async function recoverSkillPlacementTransaction(
+ stateDirectory: string,
+ canonicalPath: string,
+ filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem,
+ options: { finalize?: boolean; signal?: AbortSignal } = {}
+): Promise {
+ const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonicalPath)
+ if (!journal) {
+ return null
+ }
+ if (!(await placementMatchesDigest(canonicalPath, journal.packageDigest, journal, filesystem))) {
+ if (
+ journal.previousReceipt &&
+ (await placementMatchesDigest(
+ canonicalPath,
+ journal.previousReceipt.packageDigest,
+ journal,
+ filesystem,
+ journal.previousReceipt.fileModes ?? []
+ ))
+ ) {
+ await Promise.all(
+ journal.actions.flatMap((action) => [
+ filesystem.remove(action.stagingPath),
+ filesystem.remove(action.backupPath)
+ ])
+ )
+ await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true })
+ return journal.previousReceipt
+ }
+ if (
+ !journal.previousReceipt &&
+ !(await placementPathExists(canonicalPath)) &&
+ !(
+ await Promise.all(
+ journal.actions.flatMap((action) => [
+ placementPathExists(action.stagingPath),
+ placementPathExists(action.backupPath)
+ ])
+ )
+ ).some(Boolean)
+ ) {
+ await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true })
+ return null
+ }
+ throw new Error('skill-placement-canonical-mismatch')
+ }
+ const allowedProviderRoots = journal.actions.map((action) => action.rootPath)
+ const placements: SkillPlacementResult[] = [
+ {
+ provider: 'agent-skills',
+ path: canonicalPath,
+ topology: 'canonical-copy',
+ status: 'installed'
+ }
+ ]
+ for (const action of journal.actions) {
+ if (!action.desired) {
+ const skipped = await settleDeselectedAction(
+ journal,
+ action,
+ filesystem,
+ allowedProviderRoots
+ )
+ if (skipped) {
+ placements.push(skipped)
+ }
+ continue
+ }
+ if (options.signal?.aborted) {
+ placements.push({
+ provider: action.provider,
+ path: action.destinationPath,
+ topology: 'independent-copy',
+ status: 'skipped',
+ errorCategory: 'skill-placement-cancelled',
+ failure: {
+ category: 'cancelled',
+ code: 'skill-placement-cancelled',
+ retryable: true
+ }
+ })
+ continue
+ }
+ const recoveredArtifacts = await settleDesiredPlacementArtifacts(journal, action, filesystem)
+ if (
+ recoveredArtifacts &&
+ (await placementPathExists(action.destinationPath)) &&
+ (await placementMatchesDigest(
+ action.destinationPath,
+ journal.packageDigest,
+ journal,
+ filesystem
+ ))
+ ) {
+ placements.push({
+ provider: action.provider,
+ path: action.destinationPath,
+ topology: (await placementIsAlias(canonicalPath, action.destinationPath, filesystem))
+ ? 'provider-alias'
+ : 'independent-copy',
+ status: 'unchanged'
+ })
+ continue
+ }
+ const destination = {
+ provider: action.provider as never,
+ rootPath: action.rootPath,
+ readsCanonicalRoot: false
+ }
+ const placement = await reconcileSkillProviderPlacement({
+ canonicalPath,
+ skillName: basename(canonicalPath),
+ destination,
+ previousReceipt: journal.previousReceipt,
+ packageDigest: journal.packageDigest,
+ fileModes: journal.fileModes,
+ filesystem,
+ transaction: { stagingPath: action.stagingPath, backupPath: action.backupPath },
+ ...(journal.wslDistro ? { targetPlatform: 'linux' as const } : {})
+ })
+ if (placement) {
+ placements.push(placement)
+ }
+ }
+ const receipt = { ...journal.receipt, placements, providers: journal.providers }
+ await writeSkillInstallReceipt(stateDirectory, receipt)
+ await writeSkillStateFile(skillPlacementJournalPath(stateDirectory, canonicalPath), {
+ ...journal,
+ receipt
+ })
+ if (options.finalize !== false) {
+ await finishSkillPlacementTransaction(stateDirectory, canonicalPath, filesystem)
+ }
+ return receipt
+}
+
+export async function finishSkillPlacementTransaction(
+ stateDirectory: string,
+ canonicalPath: string,
+ filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem
+): Promise {
+ const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonicalPath)
+ if (!journal) {
+ return
+ }
+ await Promise.all(
+ journal.actions.map(async (action) => {
+ if (action.desired) {
+ await settleDesiredPlacementArtifacts(journal, action, filesystem)
+ }
+ await filesystem.remove(action.stagingPath)
+ if (await placementPathExists(action.backupPath)) {
+ if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) {
+ throw new Error('skill-placement-recovery-conflict')
+ }
+ await filesystem.remove(action.backupPath)
+ }
+ })
+ )
+ await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true })
+}
diff --git a/src/main/skills/skill-provider-destinations.test.ts b/src/main/skills/skill-provider-destinations.test.ts
new file mode 100644
index 00000000000..b755c4068e8
--- /dev/null
+++ b/src/main/skills/skill-provider-destinations.test.ts
@@ -0,0 +1,167 @@
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { selectedOrDetectedSkillProviders } from '../../shared/skill-install-providers'
+import { resolveSkillProviderDestinations } from './skill-provider-destinations'
+
+const HOME = join('/home', 'dev')
+const WORKSPACE = join('/repos', 'orca')
+
+describe('resolveSkillProviderDestinations', () => {
+ it('gives an agent that reads the canonical root no placement of its own', () => {
+ const destinations = resolveSkillProviderDestinations({
+ scope: 'workspace',
+ homeDirectory: HOME,
+ workspaceDirectory: WORKSPACE,
+ detectedProviders: ['codex']
+ })
+ expect(destinations).toEqual([
+ {
+ provider: 'codex',
+ readsCanonicalRoot: true,
+ rootPath: join(WORKSPACE, '.agents', 'skills')
+ }
+ ])
+ })
+
+ // Why: Codex and Cursor read a project's .agents/skills but keep their own
+ // home directory, so the same agent needs a placement at one scope only.
+ it('resolves scope-specific roots for an agent that is canonical in a workspace only', () => {
+ const workspace = resolveSkillProviderDestinations({
+ scope: 'workspace',
+ homeDirectory: HOME,
+ workspaceDirectory: WORKSPACE,
+ detectedProviders: ['cursor']
+ })
+ const global = resolveSkillProviderDestinations({
+ scope: 'global',
+ homeDirectory: HOME,
+ detectedProviders: ['cursor']
+ })
+ expect(workspace[0]).toMatchObject({ readsCanonicalRoot: true })
+ expect(global[0]).toMatchObject({
+ readsCanonicalRoot: false,
+ rootPath: join(HOME, '.cursor', 'skills')
+ })
+ })
+
+ it('places every detected agent that owns a directory', () => {
+ const destinations = resolveSkillProviderDestinations({
+ scope: 'global',
+ homeDirectory: HOME,
+ detectedProviders: ['codex', 'claude', 'droid', 'grok', 'aug', 'continue', 'trae']
+ })
+ expect(destinations.map((destination) => destination.rootPath)).toEqual([
+ join(HOME, '.agents', 'skills'),
+ join(HOME, '.claude', 'skills'),
+ join(HOME, '.factory', 'skills'),
+ join(HOME, '.continue', 'skills'),
+ join(HOME, '.trae-cn', 'skills'),
+ join(HOME, '.grok', 'skills'),
+ join(HOME, '.augment', 'skills')
+ ])
+ })
+
+ it('uses the canonical root for Codex at global scope', () => {
+ expect(
+ resolveSkillProviderDestinations({
+ scope: 'global',
+ homeDirectory: HOME,
+ detectedProviders: ['codex']
+ })
+ ).toEqual([
+ { provider: 'codex', readsCanonicalRoot: true, rootPath: join(HOME, '.agents', 'skills') }
+ ])
+ })
+
+ it('honors custom global config roots without changing workspace roots', () => {
+ const customClaudeRoot = join('/srv', 'claude', 'skills')
+ expect(
+ resolveSkillProviderDestinations({
+ scope: 'global',
+ homeDirectory: HOME,
+ detectedProviders: ['claude'],
+ providerRootOverrides: { claude: customClaudeRoot }
+ })
+ ).toEqual([{ provider: 'claude', readsCanonicalRoot: false, rootPath: customClaudeRoot }])
+ expect(
+ resolveSkillProviderDestinations({
+ scope: 'workspace',
+ homeDirectory: HOME,
+ workspaceDirectory: WORKSPACE,
+ detectedProviders: ['claude'],
+ providerRootOverrides: { claude: customClaudeRoot }
+ })[0]?.rootPath
+ ).toBe(join(WORKSPACE, '.claude', 'skills'))
+ })
+
+ it('treats a custom root equal to canonical as canonical', () => {
+ expect(
+ resolveSkillProviderDestinations({
+ scope: 'global',
+ homeDirectory: HOME,
+ detectedProviders: ['claude'],
+ providerRootOverrides: { claude: join(HOME, '.agents', 'skills') }
+ })
+ ).toEqual([
+ {
+ provider: 'claude',
+ readsCanonicalRoot: true,
+ rootPath: join(HOME, '.agents', 'skills')
+ }
+ ])
+ })
+
+ it('rejects two providers claiming one noncanonical root', () => {
+ const sharedRoot = join(HOME, '.custom-agent', 'skills')
+ expect(() =>
+ resolveSkillProviderDestinations({
+ scope: 'global',
+ homeDirectory: HOME,
+ detectedProviders: ['claude', 'grok'],
+ providerRootOverrides: { claude: sharedRoot, grok: sharedRoot }
+ })
+ ).toThrow('skill-install-provider-root-collision')
+ })
+
+ it('ignores agents Orca cannot place skills for', () => {
+ expect(
+ resolveSkillProviderDestinations({
+ scope: 'global',
+ homeDirectory: HOME,
+ detectedProviders: ['opencode', 'goose', 'not-an-agent']
+ })
+ ).toEqual([])
+ })
+
+ it('requires a workspace directory for workspace scope', () => {
+ expect(() =>
+ resolveSkillProviderDestinations({
+ scope: 'workspace',
+ homeDirectory: HOME,
+ detectedProviders: ['claude']
+ })
+ ).toThrow('skill-install-workspace-required')
+ })
+})
+
+describe('selectedOrDetectedSkillProviders', () => {
+ // Why: removal passes no selection so it can still clean roots an earlier
+ // install wrote, even if the user has since narrowed their agents.
+ it('keeps every detected agent when nothing was selected', () => {
+ expect(selectedOrDetectedSkillProviders(['claude', 'codex'], undefined)).toEqual([
+ 'claude',
+ 'codex'
+ ])
+ })
+
+ it('honors selected agents even when they are not currently detected', () => {
+ expect(selectedOrDetectedSkillProviders(['claude', 'codex'], ['claude', 'cursor'])).toEqual([
+ 'claude',
+ 'cursor'
+ ])
+ })
+
+ it('places nothing extra when the choice is empty', () => {
+ expect(selectedOrDetectedSkillProviders(['claude', 'codex'], [])).toEqual([])
+ })
+})
diff --git a/src/main/skills/skill-provider-destinations.ts b/src/main/skills/skill-provider-destinations.ts
index 9a37ec70216..361b0932f10 100644
--- a/src/main/skills/skill-provider-destinations.ts
+++ b/src/main/skills/skill-provider-destinations.ts
@@ -1,6 +1,11 @@
-import { join } from 'node:path'
+import { join, resolve } from 'node:path'
+import {
+ isSkillInstallProviderId,
+ SKILL_INSTALL_PROVIDERS,
+ type SkillInstallProviderId
+} from '../../shared/skill-install-providers'
-export type OrcaSkillProviderId = 'codex' | 'claude'
+export type OrcaSkillProviderId = SkillInstallProviderId
export type SkillProviderDestination = {
provider: OrcaSkillProviderId
@@ -8,37 +13,49 @@ export type SkillProviderDestination = {
rootPath: string
}
-const SUPPORTED_PROVIDER_IDS = new Set(['codex', 'claude'])
+export type SkillProviderRootOverrides = Partial>
+
+function normalizedPath(path: string): string {
+ const normalized = resolve(path)
+ return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
+}
export function resolveSkillProviderDestinations(input: {
scope: 'global' | 'workspace'
homeDirectory: string
workspaceDirectory?: string
detectedProviders: readonly string[]
+ providerRootOverrides?: SkillProviderRootOverrides
}): SkillProviderDestination[] {
- const detected = new Set(
- input.detectedProviders.filter((provider): provider is OrcaSkillProviderId =>
- SUPPORTED_PROVIDER_IDS.has(provider as OrcaSkillProviderId)
- )
- )
+ const detected = new Set(input.detectedProviders.filter(isSkillInstallProviderId))
const scopeRoot = input.scope === 'global' ? input.homeDirectory : input.workspaceDirectory
if (!scopeRoot) {
throw new Error('skill-install-workspace-required')
}
const destinations: SkillProviderDestination[] = []
- if (detected.has('codex')) {
- destinations.push({
- provider: 'codex',
- readsCanonicalRoot: true,
- rootPath: join(scopeRoot, '.agents', 'skills')
- })
- }
- if (detected.has('claude')) {
- destinations.push({
- provider: 'claude',
- readsCanonicalRoot: false,
- rootPath: join(scopeRoot, '.claude', 'skills')
- })
+ const canonicalRoot = join(scopeRoot, '.agents', 'skills')
+ const claimedPlacementRoots = new Set()
+ for (const provider of SKILL_INSTALL_PROVIDERS) {
+ if (!detected.has(provider.id)) {
+ continue
+ }
+ const segments = input.scope === 'global' ? provider.globalSegments : provider.workspaceSegments
+ const overriddenRoot =
+ input.scope === 'global' ? input.providerRootOverrides?.[provider.id] : undefined
+ const rootPath = overriddenRoot
+ ? overriddenRoot
+ : segments
+ ? join(scopeRoot, ...segments)
+ : canonicalRoot
+ const readsCanonicalRoot = normalizedPath(rootPath) === normalizedPath(canonicalRoot)
+ if (!readsCanonicalRoot) {
+ const normalizedRoot = normalizedPath(rootPath)
+ if (claimedPlacementRoots.has(normalizedRoot)) {
+ throw new Error('skill-install-provider-root-collision')
+ }
+ claimedPlacementRoots.add(normalizedRoot)
+ }
+ destinations.push({ provider: provider.id, readsCanonicalRoot, rootPath })
}
return destinations
}
diff --git a/src/main/skills/skill-provider-runtime-roots.test.ts b/src/main/skills/skill-provider-runtime-roots.test.ts
new file mode 100644
index 00000000000..07d9d422dac
--- /dev/null
+++ b/src/main/skills/skill-provider-runtime-roots.test.ts
@@ -0,0 +1,52 @@
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import {
+ resolveEnvironmentSkillProviderRoots,
+ resolveWslGrokSkillProviderRoot,
+ withClaudeSkillProviderRoot
+} from './skill-provider-runtime-roots'
+
+describe('skill provider runtime roots', () => {
+ it('maps Claude and Grok config homes to their global skill roots', () => {
+ expect(
+ resolveEnvironmentSkillProviderRoots({
+ CLAUDE_CONFIG_DIR: join('/srv', 'claude'),
+ GROK_HOME: join('/srv', 'grok')
+ })
+ ).toEqual({
+ claude: join('/srv', 'claude', 'skills'),
+ grok: join('/srv', 'grok', 'skills')
+ })
+ })
+
+ it('rejects relative config roots and lets a target-specific Claude root win', () => {
+ const roots = resolveEnvironmentSkillProviderRoots({
+ CLAUDE_CONFIG_DIR: '../claude',
+ GROK_HOME: '../grok'
+ })
+ expect(roots).toEqual({})
+ expect(withClaudeSkillProviderRoot(roots, join('/managed', 'claude'))).toEqual({
+ claude: join('/managed', 'claude', 'skills')
+ })
+ })
+
+ it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => {
+ await expect(
+ resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n')
+ ).resolves.toBe('\\\\wsl.localhost\\Ubuntu-24.04\\srv\\grok\\skills')
+ })
+
+ it('ignores unsafe or missing WSL GROK_HOME values', async () => {
+ await expect(
+ resolveWslGrokSkillProviderRoot('Ubuntu', async () => '../grok')
+ ).resolves.toBeNull()
+ await expect(
+ resolveWslGrokSkillProviderRoot('Ubuntu', async () => '/srv/grok\0other')
+ ).resolves.toBeNull()
+ await expect(
+ resolveWslGrokSkillProviderRoot('Ubuntu', async () => {
+ throw new Error('probe failed')
+ })
+ ).resolves.toBeNull()
+ })
+})
diff --git a/src/main/skills/skill-provider-runtime-roots.ts b/src/main/skills/skill-provider-runtime-roots.ts
new file mode 100644
index 00000000000..8f2a4db2196
--- /dev/null
+++ b/src/main/skills/skill-provider-runtime-roots.ts
@@ -0,0 +1,90 @@
+import { execFile } from 'node:child_process'
+import { isAbsolute, join, resolve } from 'node:path'
+import { toWindowsWslPath } from '../../shared/wsl-paths'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
+
+const PROVIDER_ROOT_MAX_LENGTH = 32_768
+const WSL_ENV_PROBE_TIMEOUT_MS = 8_000
+const WSL_ENV_PROBE_MAX_BYTES = 4_097
+const WSL_GROK_HOME_SCRIPT = [
+ 'entry=$(getent passwd "$(id -u)" 2>/dev/null || true)',
+ 'login_shell=${entry##*:}',
+ 'case "$login_shell" in /*) ;; *) login_shell=/bin/sh ;; esac',
+ `exec "$login_shell" -lc 'printf %s "\${GROK_HOME:-}" | head -c ${WSL_ENV_PROBE_MAX_BYTES}'`
+].join('\n')
+
+function normalizedRoot(value: string | undefined): string | null {
+ const candidate = value?.trim()
+ if (
+ !candidate ||
+ candidate.length > PROVIDER_ROOT_MAX_LENGTH ||
+ candidate.includes('\0') ||
+ !isAbsolute(candidate)
+ ) {
+ return null
+ }
+ return resolve(candidate)
+}
+
+export function resolveEnvironmentSkillProviderRoots(
+ env: NodeJS.ProcessEnv = process.env
+): SkillProviderRootOverrides {
+ const claudeConfig = normalizedRoot(env.CLAUDE_CONFIG_DIR)
+ const grokHome = normalizedRoot(env.GROK_HOME)
+ return {
+ ...(claudeConfig ? { claude: join(claudeConfig, 'skills') } : {}),
+ ...(grokHome ? { grok: join(grokHome, 'skills') } : {})
+ }
+}
+
+export function withClaudeSkillProviderRoot(
+ roots: SkillProviderRootOverrides,
+ claudeConfigDirectory: string | null | undefined
+): SkillProviderRootOverrides {
+ const configDirectory = normalizedRoot(claudeConfigDirectory ?? undefined)
+ return configDirectory ? { ...roots, claude: join(configDirectory, 'skills') } : roots
+}
+
+type WslEnvironmentProbe = (distro: string) => Promise
+
+function probeWslGrokHome(distro: string): Promise {
+ return new Promise((resolveProbe, reject) => {
+ execFile(
+ 'wsl.exe',
+ ['-d', distro, '--exec', 'sh', '-c', WSL_GROK_HOME_SCRIPT],
+ {
+ encoding: 'utf8',
+ timeout: WSL_ENV_PROBE_TIMEOUT_MS,
+ maxBuffer: WSL_ENV_PROBE_MAX_BYTES,
+ windowsHide: true
+ },
+ (error, stdout) => (error ? reject(error) : resolveProbe(stdout))
+ )
+ })
+}
+
+export async function resolveWslGrokSkillProviderRoot(
+ distro: string,
+ probe: WslEnvironmentProbe = probeWslGrokHome
+): Promise {
+ try {
+ const value = await probe(distro)
+ const candidate = value.trim()
+ if (
+ !candidate ||
+ candidate.length >= WSL_ENV_PROBE_MAX_BYTES ||
+ !candidate.startsWith('/') ||
+ candidate.includes('\\') ||
+ Array.from(candidate).some((character) => {
+ const code = character.charCodeAt(0)
+ return code <= 0x1f || code === 0x7f
+ })
+ ) {
+ return null
+ }
+ const grokHome = candidate.replace(/\/+$/u, '') || '/'
+ return toWindowsWslPath(`${grokHome === '/' ? '' : grokHome}/skills`, distro)
+ } catch {
+ return null
+ }
+}
diff --git a/src/main/skills/skill-remote-install-service.test.ts b/src/main/skills/skill-remote-install-service.test.ts
index 29da1ab8911..0bd3552a3d5 100644
--- a/src/main/skills/skill-remote-install-service.test.ts
+++ b/src/main/skills/skill-remote-install-service.test.ts
@@ -81,6 +81,19 @@ describe('installSkillOnRemoteRuntime', () => {
mocks.transferSkillPackageToRuntime.mockReset()
})
+ it('requires a host update before sending an explicit provider choice', async () => {
+ await expect(
+ installSkillOnRemoteRuntime({
+ userDataPath: '/state',
+ environmentId: 'environment-1',
+ request: { ...request, providers: ['claude'] },
+ capabilities: ['skills.install.v1'],
+ requireHttps: true
+ })
+ ).rejects.toThrow('skill-install-remote-update-required')
+ expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
+ })
+
it('uses direct remote download when the runtime can reach storage', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue(success(result))
diff --git a/src/main/skills/skill-remote-install-service.ts b/src/main/skills/skill-remote-install-service.ts
index 6b719adf3c6..6451f868a7f 100644
--- a/src/main/skills/skill-remote-install-service.ts
+++ b/src/main/skills/skill-remote-install-service.ts
@@ -15,6 +15,7 @@ import {
import {
SKILL_BUNDLE_INSTALL_CAPABILITY,
SKILL_INSTALL_PROGRESS_CAPABILITY,
+ SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
} from '../../shared/skill-install-capability'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
@@ -120,14 +121,21 @@ export async function installSkillOnRemoteRuntime(input: {
requireHttps: boolean
signal?: AbortSignal
}): Promise {
- if (input.request.ingress.kind !== 'download-grant') {
+ const request = input.request
+ if (
+ request.providers !== undefined &&
+ !input.capabilities.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)
+ ) {
+ throw new Error('skill-install-remote-update-required')
+ }
+ if (request.ingress.kind !== 'download-grant') {
throw new Error('skill-install-remote-ingress-invalid')
}
- const grant = input.request.ingress
+ const grant = request.ingress
const direct = await retrySkillTransferRpc({
signal: input.signal,
retryable: retryableRemoteInstallTransportError,
- call: () => install(input.userDataPath, input.environmentId, input.request, input.signal)
+ call: () => install(input.userDataPath, input.environmentId, request, input.signal)
})
if (!isDirectDownloadUnavailable(direct, input.requireHttps)) {
if (direct.ok !== true) {
@@ -150,8 +158,8 @@ export async function installSkillOnRemoteRuntime(input: {
const transfer = await transferSkillPackageToRuntime({
userDataPath: input.userDataPath,
environmentId: input.environmentId,
- transferId: input.request.operationId,
- package: input.request.package,
+ transferId: request.operationId,
+ package: request.package,
grant,
requireHttps: input.requireHttps,
signal: input.signal
@@ -161,7 +169,7 @@ export async function installSkillOnRemoteRuntime(input: {
input.userDataPath,
input.environmentId,
{
- ...input.request,
+ ...request,
ingress: { kind: 'staged-upload', uploadId: transfer.uploadId }
},
input.signal
@@ -186,7 +194,14 @@ export async function installSkillBundleOnRemoteRuntime(input: {
signal?: AbortSignal
onProgress?: (progress: SkillBundleInstallProgress) => void
}): Promise {
- if (input.request.ingress.kind !== 'download-grant') {
+ const request = input.request
+ if (
+ request.providers !== undefined &&
+ !input.capabilities.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)
+ ) {
+ throw new Error('skill-bundle-remote-update-required')
+ }
+ if (request.ingress.kind !== 'download-grant') {
throw new Error('skill-bundle-remote-ingress-invalid')
}
if (!input.capabilities.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) {
@@ -200,21 +215,16 @@ export async function installSkillBundleOnRemoteRuntime(input: {
input.onProgress && input.capabilities.includes(SKILL_INSTALL_PROGRESS_CAPABILITY)
? startSkillInstallProgressPolling({
read: () =>
- readBundleInstallProgress(
- input.userDataPath,
- input.environmentId,
- input.request.operationId
- ),
+ readBundleInstallProgress(input.userDataPath, input.environmentId, request.operationId),
onProgress: input.onProgress
})
: null
try {
- const grant = input.request.ingress
+ const grant = request.ingress
const direct = await retrySkillTransferRpc({
signal: input.signal,
retryable: retryableRemoteInstallTransportError,
- call: () =>
- installBundle(input.userDataPath, input.environmentId, input.request, input.signal)
+ call: () => installBundle(input.userDataPath, input.environmentId, request, input.signal)
})
if (!isDirectDownloadUnavailable(direct, input.requireHttps)) {
if (direct.ok !== true) {
@@ -236,8 +246,8 @@ export async function installSkillBundleOnRemoteRuntime(input: {
const transfer = await transferSkillPackageToRuntime({
userDataPath: input.userDataPath,
environmentId: input.environmentId,
- transferId: input.request.operationId,
- package: input.request.package,
+ transferId: request.operationId,
+ package: request.package,
grant,
requireHttps: input.requireHttps,
signal: input.signal
@@ -247,7 +257,7 @@ export async function installSkillBundleOnRemoteRuntime(input: {
input.userDataPath,
input.environmentId,
{
- ...input.request,
+ ...request,
ingress: { kind: 'staged-upload', uploadId: transfer.uploadId }
},
input.signal
diff --git a/src/main/skills/skill-removable-placement.ts b/src/main/skills/skill-removable-placement.ts
index 827282763d8..a4b5b66590b 100644
--- a/src/main/skills/skill-removable-placement.ts
+++ b/src/main/skills/skill-removable-placement.ts
@@ -32,7 +32,11 @@ export async function isRemovableSkillPlacement(input: {
allowedProviderRoots: readonly string[]
filesystem: SkillInstallFilesystem
}): Promise {
- if (input.placement.topology === 'canonical-copy') {
+ if (
+ input.placement.topology === 'canonical-copy' ||
+ input.placement.status === 'failed' ||
+ input.placement.status === 'skipped'
+ ) {
return false
}
if (
diff --git a/src/main/skills/skill-remove-transaction.test.ts b/src/main/skills/skill-remove-transaction.test.ts
index 96bb88f4ea4..fc6b5699cc5 100644
--- a/src/main/skills/skill-remove-transaction.test.ts
+++ b/src/main/skills/skill-remove-transaction.test.ts
@@ -1,10 +1,11 @@
-import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
+import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createSkillPackageArchive } from './skill-package-creation'
import {
readSkillInstallReceipt,
+ writeSkillInstallReceipt,
writeSkillStateFile,
type SkillInstallReceiptV1
} from './skill-install-provenance'
@@ -154,6 +155,35 @@ describe('skill removal recovery', () => {
expect(await readFile(join(backupPath, 'local.md'), 'utf8')).toBe('changed')
})
+ it('removes an owned placement after its provider config root changes', async () => {
+ const fixture = await installedFixture()
+ const oldProviderRoot = join(fixture.root, 'old-claude-config', 'skills')
+ const oldPlacementPath = join(oldProviderRoot, basename(fixture.canonicalPath))
+ await cp(fixture.canonicalPath, oldPlacementPath, { recursive: true })
+ await writeSkillInstallReceipt(fixture.stateDirectory, {
+ ...fixture.receipt,
+ placements: [
+ ...fixture.receipt.placements,
+ {
+ provider: 'claude',
+ path: oldPlacementPath,
+ topology: 'independent-copy',
+ status: 'installed'
+ }
+ ]
+ })
+
+ await expect(
+ removeLocalSharedSkill({
+ operationId: 'remove-custom-root',
+ canonicalPath: fixture.canonicalPath,
+ stateDirectory: fixture.stateDirectory,
+ allowedProviderRoots: [join(fixture.root, 'new-claude-config', 'skills')]
+ })
+ ).resolves.toMatchObject({ status: 'removed' })
+ await expect(lstat(oldPlacementPath)).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
it.each(
['prepared', 'moving', 'receipt-removed'].flatMap((phase) =>
['before', 'after'].map((boundary) => [phase, boundary] as const)
diff --git a/src/main/skills/skill-remove-transaction.ts b/src/main/skills/skill-remove-transaction.ts
index 1fc383aaaa0..439e6886477 100644
--- a/src/main/skills/skill-remove-transaction.ts
+++ b/src/main/skills/skill-remove-transaction.ts
@@ -21,6 +21,9 @@ import {
type RemovalMove,
type SkillRemovalJournalV1
} from './skill-remove-recovery'
+import { recoverSkillPlacementTransaction } from './skill-placement-transaction'
+import { recoverSkillInstallTransaction } from './skill-install-recovery'
+import { historicalSuccessfulProviderRoots } from './skill-install-historical-provider-roots'
export {
recoverSkillRemovalTransaction,
@@ -105,7 +108,14 @@ export async function removeLocalSharedSkill(
path: skillInstallLockPath(input.stateDirectory, input.canonicalPath)
})
try {
+ const historicalReceipt = await readSkillInstallReceipt(
+ input.stateDirectory,
+ input.canonicalPath
+ )
+ filesystem.authorizeRoots?.(historicalSuccessfulProviderRoots(historicalReceipt))
await recoverSkillRemovalTransaction(input.stateDirectory, input.canonicalPath, filesystem)
+ await recoverSkillInstallTransaction(input.stateDirectory, input.canonicalPath, filesystem)
+ await recoverSkillPlacementTransaction(input.stateDirectory, input.canonicalPath, filesystem)
const receipt = await readSkillInstallReceipt(input.stateDirectory, input.canonicalPath)
const state = await inspectCanonicalRemoval(input, receipt)
if (!receipt) {
@@ -118,6 +128,11 @@ export async function removeLocalSharedSkill(
return conflictResult(input, receipt, 'modified')
}
+ const allowedProviderRoots = [
+ ...input.allowedProviderRoots,
+ ...historicalSuccessfulProviderRoots(receipt)
+ ]
+
const removedPlacements: SkillPlacementResult[] = []
const skippedPlacements: SkillPlacementResult[] = []
const moves: RemovalMove[] = []
@@ -129,7 +144,7 @@ export async function removeLocalSharedSkill(
await isRemovableSkillPlacement({
placement,
receipt,
- allowedProviderRoots: input.allowedProviderRoots,
+ allowedProviderRoots,
filesystem
})
) {
@@ -188,7 +203,7 @@ export async function removeLocalSharedSkill(
movedCount: 0,
moves,
receipt,
- allowedProviderRoots: [...input.allowedProviderRoots]
+ allowedProviderRoots: [...new Set(allowedProviderRoots)]
}
const statePath = skillRemovalJournalPath(input.stateDirectory, input.canonicalPath)
await persistRemovalJournal(statePath, journal, dependencies)
diff --git a/src/main/skills/skill-share-preparation-service.ts b/src/main/skills/skill-share-preparation-service.ts
index 053901af24e..f974a95385b 100644
--- a/src/main/skills/skill-share-preparation-service.ts
+++ b/src/main/skills/skill-share-preparation-service.ts
@@ -133,6 +133,7 @@ export class SkillSharePreparationService {
archiveSha256: preparation.created.archiveSha256,
compressedBytes: preparation.created.compressedBytes,
packageId: preparation.created.manifest.packageId,
+ idempotencyKey: input.preparationId,
releaseNotes: input.releaseNotes,
signal: controller.signal,
onProgress: (progress) =>
diff --git a/src/main/skills/skill-ssh-relay-service.test.ts b/src/main/skills/skill-ssh-relay-service.test.ts
index a2e1e9ae6fa..c23d092362b 100644
--- a/src/main/skills/skill-ssh-relay-service.test.ts
+++ b/src/main/skills/skill-ssh-relay-service.test.ts
@@ -49,6 +49,19 @@ function request(bytes: Buffer) {
}
describe('installSkillOnSshHost', () => {
+ it('requires a relay update before sending an explicit provider choice', async () => {
+ const requestHostRpc = vi.fn(async () => ({ capabilities: ['skills.install.v1'] }))
+ await expect(
+ installSkillOnSshHost({
+ provider: { requestHostRpc } as unknown as IPtyProvider,
+ userDataPath: await userDataPath(),
+ request: { ...request(Buffer.from('archive')), providers: ['claude'] },
+ requireHttps: true
+ })
+ ).rejects.toThrow('skill-install-ssh-update-required')
+ expect(requestHostRpc).toHaveBeenCalledOnce()
+ })
+
it('uses client-mediated upload only after direct host download fails', async () => {
const bytes = Buffer.from('private skill archive')
let received = 0
diff --git a/src/main/skills/skill-ssh-relay-service.ts b/src/main/skills/skill-ssh-relay-service.ts
index 91360eec830..a2ab25bdf2f 100644
--- a/src/main/skills/skill-ssh-relay-service.ts
+++ b/src/main/skills/skill-ssh-relay-service.ts
@@ -1,5 +1,6 @@
import {
SKILL_INSTALL_CAPABILITY,
+ SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
} from '../../shared/skill-install-capability'
@@ -51,6 +52,10 @@ export async function installSkillOnSshHost(input: {
}): Promise {
const client = requireSkillSshRelayClient(input.provider)
const supported = await skillSshRelayCapabilities(client)
+ const request = input.request
+ if (request.providers !== undefined && !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) {
+ throw new Error('skill-install-ssh-update-required')
+ }
if (!supported.includes(SKILL_INSTALL_CAPABILITY)) {
recordSkillCapabilityAbsence({
capability: SKILL_INSTALL_CAPABILITY,
@@ -66,14 +71,14 @@ export async function installSkillOnSshHost(input: {
call: () =>
client(
SKILL_SSH_RELAY_INSTALL_METHOD,
- { request: input.request, workspace: input.workspace },
+ { request: request, workspace: input.workspace },
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
)
})
)
} catch (error) {
if (
- input.request.ingress.kind !== 'download-grant' ||
+ request.ingress.kind !== 'download-grant' ||
!shouldUseSkillSshClientTransfer(error, input.requireHttps)
) {
throw error
@@ -97,7 +102,7 @@ export async function installSkillOnSshHost(input: {
SKILL_SSH_RELAY_INSTALL_METHOD,
{
request: {
- ...input.request,
+ ...request,
ingress: { kind: 'staged-upload', uploadId }
},
workspace: input.workspace
diff --git a/src/main/skills/skill-transaction-startup-recovery.ts b/src/main/skills/skill-transaction-startup-recovery.ts
index 012cecb5e7c..ab8928f9fd9 100644
--- a/src/main/skills/skill-transaction-startup-recovery.ts
+++ b/src/main/skills/skill-transaction-startup-recovery.ts
@@ -18,6 +18,8 @@ import {
} from './skill-remove-recovery'
import { startSkillPhaseOperation } from './skill-operation-observability'
import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
+import { readSkillPlacementRecoveryJournal } from './skill-placement-recovery-journal'
+import { recoverSkillPlacementTransaction } from './skill-placement-transaction'
const MAX_PENDING_TRANSACTION_JOURNALS = 64
const MAX_TRANSACTION_JOURNAL_BYTES = 4 * 1024 * 1024
@@ -27,6 +29,7 @@ type PendingTransaction = {
journalKey: string
install: boolean
removal: boolean
+ placement: boolean
}
export type SkillTransactionStartupRecoveryReport = {
@@ -46,7 +49,7 @@ function failureCode(error: unknown): string {
async function scanJournalDirectory(
stateDirectory: string,
- directoryName: 'journals' | 'removal-journals'
+ directoryName: 'journals' | 'removal-journals' | 'placement-journals'
): Promise<{
candidates: { canonicalPath: string; journalKey: string }[]
failures: { journalKey: string; code: string }[]
@@ -106,13 +109,14 @@ function pendingTransactions(
const pending = new Map()
const add = (
candidate: { canonicalPath: string; journalKey: string },
- kind: 'install' | 'removal'
+ kind: 'install' | 'removal' | 'placement'
): void => {
const current = pending.get(candidate.canonicalPath) ?? {
canonicalPath: candidate.canonicalPath,
journalKey: candidate.journalKey,
install: false,
- removal: false
+ removal: false,
+ placement: false
}
current[kind] = true
pending.set(candidate.canonicalPath, current)
@@ -125,36 +129,65 @@ function pendingTransactions(
async function recoverPendingSkillTransactionsUnobserved(
stateDirectory: string
): Promise {
- const [installs, removals, extractions, locks] = await Promise.all([
+ const [installs, removals, placements, extractions, locks] = await Promise.all([
scanJournalDirectory(stateDirectory, 'journals'),
scanJournalDirectory(stateDirectory, 'removal-journals'),
+ scanJournalDirectory(stateDirectory, 'placement-journals'),
recoverPendingSkillExtractions(stateDirectory),
reclaimDeadSkillInstallLocks(stateDirectory)
])
const report: SkillTransactionStartupRecoveryReport = {
- scanned: installs.candidates.length + removals.candidates.length + extractions.scanned,
+ scanned:
+ installs.candidates.length +
+ removals.candidates.length +
+ placements.candidates.length +
+ extractions.scanned,
recovered: extractions.recovered,
orphanedExtractionsRecovered: extractions.recovered,
orphanedLocksReclaimed: locks.reclaimed,
- failures: [...installs.failures, ...removals.failures, ...extractions.failures],
- truncated: installs.truncated || removals.truncated || extractions.truncated || locks.truncated
+ failures: [
+ ...installs.failures,
+ ...removals.failures,
+ ...placements.failures,
+ ...extractions.failures
+ ],
+ truncated:
+ installs.truncated ||
+ removals.truncated ||
+ placements.truncated ||
+ extractions.truncated ||
+ locks.truncated
}
- for (const pending of pendingTransactions(installs.candidates, removals.candidates)) {
+ const pending = pendingTransactions(installs.candidates, removals.candidates)
+ for (const placement of placements.candidates) {
+ const current = pending.find((candidate) => candidate.canonicalPath === placement.canonicalPath)
+ if (current) {
+ current.placement = true
+ } else {
+ pending.push({ ...placement, install: false, removal: false, placement: true })
+ }
+ }
+ for (const pendingTransaction of pending) {
let releaseLock: (() => Promise) | null = null
try {
releaseLock = await acquireSkillInstallLock({
- path: skillInstallLockPath(stateDirectory, pending.canonicalPath)
+ path: skillInstallLockPath(stateDirectory, pendingTransaction.canonicalPath)
})
- const installJournal = pending.install
- ? await readSkillInstallRecoveryJournal(stateDirectory, pending.canonicalPath)
+ const installJournal = pendingTransaction.install
+ ? await readSkillInstallRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath)
: null
- const removalJournal = pending.removal
- ? await readSkillRemovalRecoveryJournal(stateDirectory, pending.canonicalPath)
+ const removalJournal = pendingTransaction.removal
+ ? await readSkillRemovalRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath)
+ : null
+ const placementJournal = pendingTransaction.placement
+ ? await readSkillPlacementRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath)
: null
const distros = new Set(
- [installJournal?.receipt.wslDistro, removalJournal?.receipt.wslDistro].filter(
- (distro): distro is string => Boolean(distro)
- )
+ [
+ installJournal?.receipt.wslDistro,
+ removalJournal?.receipt.wslDistro,
+ placementJournal?.wslDistro
+ ].filter((distro): distro is string => Boolean(distro))
)
if (distros.size > 1 || (distros.size && process.platform !== 'win32')) {
throw new Error('skill-transaction-wsl-recovery-unavailable')
@@ -162,23 +195,46 @@ async function recoverPendingSkillTransactionsUnobserved(
const distro = [...distros][0]
const filesystem = distro
? new WslSkillInstallFilesystem(distro, [
- dirname(pending.canonicalPath),
- ...(removalJournal?.allowedProviderRoots ?? [])
+ dirname(pendingTransaction.canonicalPath),
+ ...(removalJournal?.allowedProviderRoots ?? []),
+ ...(placementJournal?.actions.map((action) => action.rootPath) ?? [])
])
: undefined
if (removalJournal) {
- await recoverSkillRemovalTransaction(stateDirectory, pending.canonicalPath, filesystem)
+ await recoverSkillRemovalTransaction(
+ stateDirectory,
+ pendingTransaction.canonicalPath,
+ filesystem
+ )
report.recovered += 1
}
if (installJournal) {
- await recoverSkillInstallTransaction(stateDirectory, pending.canonicalPath, filesystem)
+ await recoverSkillInstallTransaction(
+ stateDirectory,
+ pendingTransaction.canonicalPath,
+ filesystem
+ )
+ report.recovered += 1
+ }
+ if (placementJournal) {
+ await recoverSkillPlacementTransaction(
+ stateDirectory,
+ pendingTransaction.canonicalPath,
+ filesystem
+ )
report.recovered += 1
}
} catch (error) {
- report.failures.push({ journalKey: pending.journalKey, code: failureCode(error) })
+ report.failures.push({
+ journalKey: pendingTransaction.journalKey,
+ code: failureCode(error)
+ })
} finally {
await releaseLock?.().catch((error) => {
- report.failures.push({ journalKey: pending.journalKey, code: failureCode(error) })
+ report.failures.push({
+ journalKey: pendingTransaction.journalKey,
+ code: failureCode(error)
+ })
})
}
}
diff --git a/src/main/skills/skill-wsl-install-filesystem.test.ts b/src/main/skills/skill-wsl-install-filesystem.test.ts
index cf259e1f27b..a85370223e5 100644
--- a/src/main/skills/skill-wsl-install-filesystem.test.ts
+++ b/src/main/skills/skill-wsl-install-filesystem.test.ts
@@ -9,7 +9,10 @@ vi.mock('node:child_process', () => ({
})
}))
-import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
+import {
+ createWslSkillInstallFilesystem,
+ WslSkillInstallFilesystem
+} from './skill-wsl-install-filesystem'
const WSL_ROOT = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.agents\\skills'
@@ -18,6 +21,32 @@ beforeEach(() => {
})
describe('WslSkillInstallFilesystem', () => {
+ it('allows every global provider destination inside the selected distro', async () => {
+ const home = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin'
+ const filesystem = createWslSkillInstallFilesystem({
+ distro: 'Ubuntu-24.04',
+ homeDirectory: home
+ })
+
+ for (const directory of [
+ '.claude',
+ '.cursor',
+ '.gemini',
+ '.factory',
+ '.continue',
+ '.trae-cn',
+ '.grok',
+ '.augment'
+ ]) {
+ await filesystem.createAlias(
+ `${home}\\.agents\\skills\\private-skill`,
+ `${home}\\${directory}\\skills\\private-skill`
+ )
+ }
+
+ expect(execFileAsyncMock).toHaveBeenCalledTimes(8)
+ })
+
it('applies and verifies manifest modes through bounded guest argv batches', async () => {
const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [WSL_ROOT])
const manifest = {
@@ -94,4 +123,20 @@ describe('WslSkillInstallFilesystem', () => {
).rejects.toThrow('skill-install-wsl-path-invalid')
expect(execFileAsyncMock).not.toHaveBeenCalled()
})
+
+ it('authorizes a historical provider root before update or removal', async () => {
+ const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [WSL_ROOT])
+ const historicalRoot =
+ '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\claude-accounts\\old\\auth\\skills'
+ filesystem.authorizeRoots([historicalRoot])
+
+ await filesystem.remove(`${historicalRoot}\\private-skill`)
+
+ expect(execFileAsyncMock).toHaveBeenCalledOnce()
+ expect(execFileAsyncMock.mock.calls[0]?.[1]).toEqual(
+ expect.arrayContaining([
+ '/home/jin/.local/share/orca/claude-accounts/old/auth/skills/private-skill'
+ ])
+ )
+ })
})
diff --git a/src/main/skills/skill-wsl-install-filesystem.ts b/src/main/skills/skill-wsl-install-filesystem.ts
index 30f37527fc7..90fa71ce66a 100644
--- a/src/main/skills/skill-wsl-install-filesystem.ts
+++ b/src/main/skills/skill-wsl-install-filesystem.ts
@@ -9,6 +9,8 @@ import {
type ObservedSkillPackage
} from './skill-package-identity'
import type { SkillInstallFilesystem, SkillInstalledFileMode } from './skill-install-filesystem'
+import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers'
+import type { SkillProviderRootOverrides } from './skill-provider-destinations'
const execFileAsync = promisify(execFile)
const BATCH_FILE_COUNT = 8
@@ -74,6 +76,15 @@ export class WslSkillInstallFilesystem implements SkillInstallFilesystem {
this.guestAllowedRoots = allowedRoots.map((path) => this.toGuestPath(path))
}
+ authorizeRoots(paths: readonly string[]): void {
+ for (const path of paths) {
+ const guestRoot = this.toGuestPath(path)
+ if (!this.guestAllowedRoots.includes(guestRoot)) {
+ this.guestAllowedRoots.push(guestRoot)
+ }
+ }
+ }
+
async prepareExtractedSkill(path: string, manifest: SkillPackageManifestV1): Promise {
const guestRoot = this.requireAllowed(path)
if (isWindowsBackedGuestPath(guestRoot)) {
@@ -223,10 +234,18 @@ export function createWslSkillInstallFilesystem(input: {
distro: string
homeDirectory: string
workspaceDirectory?: string
+ providerRootOverrides?: SkillProviderRootOverrides
}): WslSkillInstallFilesystem {
const scopeRoot = input.workspaceDirectory ?? input.homeDirectory
+ const scope = input.workspaceDirectory ? 'workspace' : 'global'
+ const providerRoots = SKILL_INSTALL_PROVIDERS.flatMap((provider) => {
+ const segments = scope === 'global' ? provider.globalSegments : provider.workspaceSegments
+ const overriddenRoot =
+ scope === 'global' ? input.providerRootOverrides?.[provider.id] : undefined
+ return overriddenRoot ? [overriddenRoot] : segments ? [join(scopeRoot, ...segments)] : []
+ })
return new WslSkillInstallFilesystem(input.distro, [
join(scopeRoot, '.agents', 'skills'),
- join(scopeRoot, '.claude', 'skills')
+ ...providerRoots
])
}
diff --git a/src/relay/skill-install-handler.test.ts b/src/relay/skill-install-handler.test.ts
index 419055254c2..8bb46cd3d1e 100644
--- a/src/relay/skill-install-handler.test.ts
+++ b/src/relay/skill-install-handler.test.ts
@@ -113,6 +113,7 @@ describe('SkillInstallHandler', () => {
it('advertises separately gateable install, upload, and management capabilities', () => {
expect(SKILL_RELAY_CAPABILITIES).toEqual([
'skills.install.v1',
+ 'skills.install-providers.v1',
'skills.install.bundle.v1',
'skills.install-progress.v1',
'skills.upload.v1',
diff --git a/src/relay/skill-install-handler.ts b/src/relay/skill-install-handler.ts
index dfcd8d8a052..7c1abe68526 100644
--- a/src/relay/skill-install-handler.ts
+++ b/src/relay/skill-install-handler.ts
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import {
SKILL_BUNDLE_INSTALL_CAPABILITY,
SKILL_INSTALL_CAPABILITY,
+ SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_INSTALL_PROGRESS_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
@@ -54,11 +55,13 @@ import {
skillInstallFailureFromError
} from '../main/skills/skill-install-operation-error'
import { recoverPendingSkillTransactions } from '../main/skills/skill-transaction-startup-recovery'
+import { resolveEnvironmentSkillProviderRoots } from '../main/skills/skill-provider-runtime-roots'
const SSH_SKILL_ENVIRONMENT_ID = 'ssh-host'
export const SKILL_RELAY_CAPABILITIES = [
SKILL_INSTALL_CAPABILITY,
+ SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_BUNDLE_INSTALL_CAPABILITY,
SKILL_INSTALL_PROGRESS_CAPABILITY,
SKILL_UPLOAD_CAPABILITY,
@@ -102,6 +105,7 @@ export class SkillInstallHandler {
requireHttps: true,
resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity),
detectProviders: this.detectProviders,
+ resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots(),
signal: context.signal
})
)
@@ -117,6 +121,7 @@ export class SkillInstallHandler {
requireHttps: true,
resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity),
detectProviders: this.detectProviders,
+ resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots(),
signal: context.signal,
onProgress: (progress) => this.installProgress.set(input.request.operationId, progress)
})
@@ -135,7 +140,8 @@ export class SkillInstallHandler {
previewSharedSkillInstall(input.request, {
authority: this.authority(input.workspace),
stateDirectory: this.stateDirectory,
- detectProviders: this.detectProviders
+ detectProviders: this.detectProviders,
+ resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots()
})
)
})
@@ -145,7 +151,8 @@ export class SkillInstallHandler {
removeSharedSkillInstall(input.request, {
authority: this.authority(input.workspace),
stateDirectory: this.stateDirectory,
- detectProviders: this.detectProviders
+ detectProviders: this.detectProviders,
+ resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots()
})
)
})
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 5f9b6567270..4b314e97fff 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1531,11 +1531,9 @@ function App(): React.JSX.Element {
const hasTabBar = tabCount >= 2
const showTitlebarExpandButton = workspaceChromeActive && !hasTabBar && effectiveActiveTabExpanded
// Activity/Space are full-page navigation surfaces (like Settings), so the worktree sidebar is hidden there.
+ // Skills is reached from the sidebar like Artifacts, so it keeps the sidebar visible to navigate away from.
const showSidebar =
- activeView !== 'settings' &&
- activeView !== 'activity' &&
- activeView !== 'space' &&
- activeView !== 'skills'
+ activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space'
// Tasks/Landing show the full titlebar only when the sidebar is collapsed; open, they mirror workspace view (creation suppresses it).
const stackedSidebarOpen =
!workspaceChromeActive && !creationLayoutActive && showSidebar && sidebarOpen
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 099478745e4..57d2e207f69 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -1679,6 +1679,13 @@ html.native-shell .app-layout {
animation: collapsible-up 180ms ease-out;
}
+@media (prefers-reduced-motion: reduce) {
+ .collapsible-height-content[data-state='open'],
+ .collapsible-height-content[data-state='closed'] {
+ animation: none;
+ }
+}
+
@keyframes collapsible-down {
from {
height: 0;
diff --git a/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx b/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx
index 4a9ce103447..cf6aec97946 100644
--- a/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx
+++ b/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'
-import { Check, CircleUserRound, Files, Smartphone } from 'lucide-react'
+import { BookOpen, Check, CircleUserRound, Files, Smartphone } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { OrcaProfileSignOutConfirmDialog } from '../orca-profiles/OrcaProfileSignOutConfirmDialog'
@@ -34,6 +35,28 @@ function accountStatusCopy(
return translate('auto.components.settings.orcaAccount.checking', 'Checking account status…')
}
+function AccountBenefit({
+ icon: Icon,
+ title,
+ description,
+ className
+}: {
+ icon: typeof Files
+ title: string
+ description: string
+ className?: string
+}): React.JSX.Element {
+ return (
+
+
+
+
{title}
+
{description}
+
+
+ )
+}
+
export function OrcaAccountSettingsPane(): React.JSX.Element {
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
@@ -120,38 +143,41 @@ export function OrcaAccountSettingsPane(): React.JSX.Element {
'Included with your account'
)}
-
-
-
-
-
- {translate(
- 'auto.components.settings.orcaAccount.artifactsTitle',
- 'Artifact sharing'
- )}
-
-
- {translate(
- 'auto.components.settings.orcaAccount.artifactsDescription',
- 'Publish HTML and Markdown files, then manage every shared link from Orca.'
- )}
-
-
-
-
-
-
-
- {translate('auto.components.settings.orcaAccount.relayTitle', 'Orca Relay')}
-
-
- {translate(
- 'auto.components.settings.orcaAccount.relayDescription',
- 'Connect Orca Mobile to this desktop across cellular or any Wi-Fi.'
- )}
-
-
+
+
+ {/* Why: a third column would squeeze all three; a full-width row
+ below keeps the pair's divider and reads as one list. */}
+
diff --git a/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx b/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx
deleted file mode 100644
index 26ae6acd50b..00000000000
--- a/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-// @vitest-environment happy-dom
-
-import '@testing-library/jest-dom/vitest'
-import { cleanup, render, screen, waitFor } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { OwnedSkillShareLinks } from './OwnedSkillShareLinks'
-
-vi.mock('@/i18n/i18n', () => ({
- translate: (_key: string, fallback: string) => fallback
-}))
-
-vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
-
-afterEach(() => {
- cleanup()
- Reflect.deleteProperty(window, 'api')
-})
-
-describe('OwnedSkillShareLinks', () => {
- it('copies and revokes owner-only shared links', async () => {
- const writeClipboardText = vi.fn().mockResolvedValue(undefined)
- const revokeShare = vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
- Object.defineProperty(window, 'api', {
- configurable: true,
- value: {
- ui: { writeClipboardText },
- skills: {
- listOwnedShares: vi.fn().mockResolvedValue({
- status: 'ok',
- value: [
- {
- id: 'share_1',
- url: 'https://share.onorca.dev/skills/share/share_1',
- packageId: 'package_1',
- name: 'team-skills',
- description: 'Team bundle',
- createdAt: '2026-08-12T00:00:00.000Z'
- }
- ]
- }),
- revokeShare
- }
- }
- })
- const user = userEvent.setup()
- render( )
-
- await screen.findByText('team-skills')
- await user.click(screen.getByRole('button', { name: 'Copy link' }))
- expect(writeClipboardText).toHaveBeenCalledWith('https://share.onorca.dev/skills/share/share_1')
-
- const unshare = screen.getByRole('button', { name: 'Unshare' })
- unshare.focus()
- await user.click(unshare)
- expect(revokeShare).not.toHaveBeenCalled()
- const confirm = screen.getByRole('button', { name: 'Confirm unshare' })
- expect(confirm).toHaveFocus()
- await user.click(confirm)
- await waitFor(() => expect(revokeShare).toHaveBeenCalledWith('share_1'))
- expect(screen.queryByText('team-skills')).not.toBeInTheDocument()
- })
-})
diff --git a/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx b/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx
deleted file mode 100644
index 1bf3c89a0fd..00000000000
--- a/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { Clipboard, Link2Off, Loader2, RefreshCw } from 'lucide-react'
-import { toast } from 'sonner'
-import type { SkillCloudOwnedShare } from '../../../../shared/skill-cloud-contract'
-import { Button } from '@/components/ui/button'
-import { translate } from '@/i18n/i18n'
-
-function inventoryError(status: string): string {
- return status === 'reconnect-required'
- ? translate(
- 'auto.components.settings.shareSkills.linksReconnect',
- 'Sign in again to manage shared links.'
- )
- : translate(
- 'auto.components.settings.shareSkills.linksUnavailable',
- 'Shared links are unavailable right now.'
- )
-}
-
-export function OwnedSkillShareLinks(): React.JSX.Element {
- const [shares, setShares] = useState([])
- const [loading, setLoading] = useState(true)
- const [busyShareId, setBusyShareId] = useState(null)
- const [confirmShareId, setConfirmShareId] = useState(null)
- const [error, setError] = useState(null)
- const generation = useRef(0)
-
- const load = useCallback(async (): Promise => {
- const current = ++generation.current
- setLoading(true)
- setError(null)
- try {
- const operation = await window.api.skills.listOwnedShares()
- if (generation.current !== current) {
- return
- }
- if (operation.status !== 'ok') {
- setError(inventoryError(operation.status))
- return
- }
- setShares(operation.value)
- } catch {
- if (generation.current === current) {
- setError(
- translate(
- 'auto.components.settings.shareSkills.linksUnavailable',
- 'Shared links are unavailable right now.'
- )
- )
- }
- } finally {
- if (generation.current === current) {
- setLoading(false)
- }
- }
- }, [])
-
- useEffect(() => {
- void load()
- return () => {
- generation.current += 1
- }
- }, [load])
-
- const copy = async (share: SkillCloudOwnedShare): Promise => {
- await window.api.ui.writeClipboardText(share.url)
- toast.success(translate('auto.components.settings.shareSkills.linkCopied', 'Share link copied'))
- }
-
- const unshare = async (share: SkillCloudOwnedShare): Promise => {
- if (confirmShareId !== share.id) {
- setConfirmShareId(share.id)
- return
- }
- setBusyShareId(share.id)
- setError(null)
- try {
- const operation = await window.api.skills.revokeShare(share.id)
- if (operation.status !== 'ok') {
- setError(inventoryError(operation.status))
- return
- }
- setShares((current) => current.filter((candidate) => candidate.id !== share.id))
- setConfirmShareId(null)
- toast.success(translate('auto.components.settings.shareSkills.linkRevoked', 'Link revoked'))
- } catch {
- setError(
- translate(
- 'auto.components.settings.shareSkills.revokeFailed',
- 'Orca could not revoke this link.'
- )
- )
- } finally {
- setBusyShareId(null)
- }
- }
-
- return (
-
-
-
-
- {translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')}
-
-
- {translate(
- 'auto.components.settings.shareSkills.activeLinksDescription',
- 'Only people with a link can open it. Unshare a link to block future inspection and installs.'
- )}
-
-
-
void load()}
- >
- {loading ? : }
- {translate('auto.components.settings.shareSkills.refreshLinks', 'Refresh')}
-
-
-
- {!loading && shares.length === 0 && !error ? (
-
- {translate(
- 'auto.components.settings.shareSkills.noActiveLinks',
- 'No active links. Publish a skill bundle from Skills to create one.'
- )}
-
- ) : null}
-
- {shares.length > 0 ? (
-
- {shares.map((share) => (
-
-
-
{share.name}
-
{share.url}
-
-
- void copy(share)}>
-
- {translate('auto.components.settings.shareSkills.copyLink', 'Copy link')}
-
- void unshare(share)}
- >
- {busyShareId === share.id ? (
-
- ) : (
-
- )}
- {confirmShareId === share.id
- ? translate(
- 'auto.components.settings.shareSkills.confirmUnshare',
- 'Confirm unshare'
- )
- : translate('auto.components.settings.shareSkills.unshare', 'Unshare')}
-
-
-
- ))}
-
- ) : null}
-
- {error ? (
-
- {error}
-
- ) : null}
-
- )
-}
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx
index 161048e58c9..8b35219db49 100644
--- a/src/renderer/src/components/settings/Settings.tsx
+++ b/src/renderer/src/components/settings/Settings.tsx
@@ -1463,7 +1463,7 @@ function Settings(): React.JSX.Element {
badge="Beta"
description={translate(
'auto.components.settings.shareSkills.description',
- 'Share one or many skills through unlisted, revocable links.'
+ 'Share your skills with an unlisted link. Anyone who has it can install them.'
)}
searchEntries={getSectionSearchEntries('share-skills')}
>
diff --git a/src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx b/src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx
index 091a4e1ddf6..74cfffcb157 100644
--- a/src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx
+++ b/src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx
@@ -3,6 +3,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { TooltipProvider } from '@/components/ui/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -65,7 +66,11 @@ describe('ShareSkillsSettingsPane', () => {
it('explains unlisted multi-skill links and opens Skills', async () => {
const user = userEvent.setup()
- render( )
+ render(
+
+
+
+ )
expect(screen.getByText('Unlisted skill links')).toBeInTheDocument()
expect(screen.getByText('Select one or more skills')).toBeInTheDocument()
@@ -82,7 +87,11 @@ describe('ShareSkillsSettingsPane', () => {
it('offers owner sign-in while explaining recipients stay signed out', async () => {
const user = userEvent.setup()
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
- render( )
+ render(
+
+
+
+ )
expect(screen.getByText('Sign in to share skills')).toBeInTheDocument()
expect(screen.getByText(/Recipients do not need an account/)).toBeInTheDocument()
@@ -93,7 +102,11 @@ describe('ShareSkillsSettingsPane', () => {
it('does not offer desktop publishing from the web client', () => {
mocks.state.isWebClient = true
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
- render( )
+ render(
+
+
+
+ )
expect(screen.getByText(/available in the Orca desktop app/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Open Skills/ })).not.toBeInTheDocument()
diff --git a/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx b/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx
index e6e0d1a39d2..70741b35fb6 100644
--- a/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx
+++ b/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx
@@ -1,15 +1,18 @@
import { useEffect } from 'react'
-import { ArrowRight, Share2 } from 'lucide-react'
+import { ArrowRight, BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { isWebClientLocation } from '@/lib/web-client-location'
import { useAppStore } from '@/store'
-import { OwnedSkillShareLinks } from './OwnedSkillShareLinks'
+import { SettingsSwitchRow } from './SettingsFormControls'
type HowToStep = { key: string; title: string; description: string }
export function ShareSkillsSettingsPane(): React.JSX.Element {
const openSkillsPage = useAppStore((state) => state.openSkillsPage)
+ const openSkillsSharedLinks = useAppStore((state) => state.openSkillsSharedLinks)
+ const settings = useAppStore((state) => state.settings)
+ const updateSettings = useAppStore((state) => state.updateSettings)
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
@@ -78,6 +81,18 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
+ {!isWebClient ? (
+ void updateSettings({ showSkillsButton: !settings?.showSkillsButton })}
+ />
+ ) : null}
+
{!signedIn ? (
@@ -116,7 +131,27 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
) : null}
- {signedIn && !isWebClient ?
: null}
+ {/* Why: the link inventory grows without bound and belongs with the page
+ that publishes it; settings keeps the preference and the way over. */}
+ {signedIn && !isWebClient ? (
+
+
+
+ {translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')}
+
+
+ {translate(
+ 'auto.components.settings.shareSkills.activeLinksDescription',
+ 'Only people with a link can open it. Unshare a link to block future inspection and installs.'
+ )}
+
+
+
+ {translate('auto.components.settings.shareSkills.manageInSkills', 'Manage in Skills')}
+
+
+
+ ) : null}
@@ -153,7 +188,7 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
onClick={openSkillsPage}
>
-
+
diff --git a/src/renderer/src/components/settings/share-skills-settings-search.ts b/src/renderer/src/components/settings/share-skills-settings-search.ts
index b4d82271eeb..8eb7f224563 100644
--- a/src/renderer/src/components/settings/share-skills-settings-search.ts
+++ b/src/renderer/src/components/settings/share-skills-settings-search.ts
@@ -7,7 +7,7 @@ export const getShareSkillsSettingsSearchEntries = createLocalizedCatalog(() =>
title: translate('auto.components.settings.shareSkills.linkTitle', 'Unlisted skill links'),
description: translate(
'auto.components.settings.shareSkills.searchDescription',
- 'Share one or more skills behind an unlisted, revocable link.'
+ 'Share your skills with an unlisted link. Anyone who has it can install them.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.shareSkills.keywordSkills', 'skills'),
diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx
index 98333ba6287..d8165ef534b 100644
--- a/src/renderer/src/components/sidebar/SidebarNav.tsx
+++ b/src/renderer/src/components/sidebar/SidebarNav.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import { Bell, CalendarClock, EyeOff, Files, Search, Smartphone } from 'lucide-react'
+import { Bell, BookOpen, CalendarClock, EyeOff, Files, Search, Smartphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
@@ -49,6 +49,12 @@ export function shouldShowArtifactsButton(
return settings?.showArtifactsButton === true
}
+export function shouldShowSkillsButton(
+ settings: Pick | null | undefined
+): boolean {
+ return settings?.showSkillsButton === true
+}
+
const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry'))
const SidebarNav = React.memo(function SidebarNav() {
@@ -60,6 +66,7 @@ const SidebarNav = React.memo(function SidebarNav() {
const openActivityPage = useAppStore((s) => s.openActivityPage)
const openMobilePage = useAppStore((s) => s.openMobilePage)
const openArtifactsPage = useAppStore((s) => s.openArtifactsPage)
+ const openSkillsPage = useAppStore((s) => s.openSkillsPage)
const openModal = useAppStore((s) => s.openModal)
const updateSettings = useAppStore((s) => s.updateSettings)
const activeView = useAppStore((s) => s.activeView)
@@ -73,10 +80,12 @@ const SidebarNav = React.memo(function SidebarNav() {
const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings))
const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings))
const showArtifactsButton = useAppStore((s) => shouldShowArtifactsButton(s.settings))
+ const showSkillsButton = useAppStore((s) => shouldShowSkillsButton(s.settings))
const automationsActive = activeView === 'automations'
const activityActive = activeView === 'activity'
const mobileActive = activeView === 'mobile'
const artifactsActive = activeView === 'artifacts'
+ const skillsActive = activeView === 'skills'
const activityUnreadCount = useActivityUnreadCount(showAgentsButton, 'sidebar-badge')
const mobileOnboardingBadge = useMobileSidebarOnboardingBadge(showMobileButton)
const hideAutomationsButton = React.useCallback(() => {
@@ -88,6 +97,9 @@ const SidebarNav = React.memo(function SidebarNav() {
const hideArtifactsButton = React.useCallback(() => {
void updateSettings({ showArtifactsButton: false })
}, [updateSettings])
+ const hideSkillsButton = React.useCallback(() => {
+ void updateSettings({ showSkillsButton: false })
+ }, [updateSettings])
return (
) : null}
+ {showSkillsButton ? (
+
+
+
+
+
+ {translate('auto.components.sidebar.SidebarNav.skills', 'Skills')}
+
+
+
+
+
+ ) : null}
{showAutomationsButton ? (
diff --git a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx
index 2f9292f3873..70e9c3dcaa2 100644
--- a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx
+++ b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx
@@ -14,6 +14,10 @@ import type { SkillInstallDestination } from '../../../../shared/skill-install-c
import { SkillBundleInstallOutcome } from './SkillBundleInstallOutcome'
import { SkillBundleInstallReview } from './SkillBundleInstallReview'
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
+import { defaultSelectedSkillProviders } from './skill-install-provider-groups'
+import { installSkillsActionLabel, retrySkillsActionLabel } from './skill-display-labels'
+import { useSkillInstallDetectedAgents } from './use-skill-install-detected-agents'
+import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers'
import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
import { useSkillInstallProgress } from './skill-install-progress-state'
import { translate } from '@/i18n/i18n'
@@ -56,6 +60,9 @@ export function SkillBundleInstallFlow(props: {
const [result, setResult] = useState(null)
const [environmentId, setEnvironmentId] = useState('local')
const [scope, setScope] = useState<'global' | 'workspace'>('global')
+ // Why: null means "follow detection"; storing the derived default instead
+ // would freeze the picker on whichever machine was selected first.
+ const [providerChoice, setProviderChoice] = useState | null>(null)
const [workspace, setWorkspace] = useState('')
const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>(
null
@@ -63,6 +70,11 @@ export function SkillBundleInstallFlow(props: {
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const installProgress = useSkillInstallProgress()
+ const detectedAgents = useSkillInstallDetectedAgents({
+ environmentId,
+ wslDistro: executionTarget?.distro ?? null
+ })
+ const providers = providerChoice ?? defaultSelectedSkillProviders(detectedAgents)
const workspaceChoices = useMemo(
() => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }),
@@ -155,6 +167,7 @@ export function SkillBundleInstallFlow(props: {
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
selectedSkillIds: [...requestedIds],
destination: target,
+ providers: [...providers],
conflictDecisions: checked.skills
.filter((skill) => CONFLICT_STATES.has(skill.currentState))
.map((skill) => ({
@@ -271,6 +284,10 @@ export function SkillBundleInstallFlow(props: {
setExecutionTarget(value)
resetPreview()
}}
+ providers={providers}
+ detectedAgents={detectedAgents}
+ onProvidersChange={setProviderChoice}
+ busy={busy}
runtimeEnvironments={runtimeEnvironments}
runtimeStatus={runtimeStatus}
sshConnections={sshConnections}
@@ -314,16 +331,8 @@ export function SkillBundleInstallFlow(props: {
{busy
? translate('auto.components.skills.SkillBundleInstallFlow.01c5a13e03', 'Installing…')
: result
- ? translate(
- 'auto.components.skills.SkillBundleInstallFlow.01c5a13e04',
- 'Retry {{value0}} skills',
- { value0: retryIds.size }
- )
- : translate(
- 'auto.components.skills.SkillBundleInstallFlow.01c5a13e05',
- 'Install {{value0}} skills',
- { value0: selectedSkillIds.size }
- )}
+ ? retrySkillsActionLabel(retryIds.size)
+ : installSkillsActionLabel(selectedSkillIds.size)}
) : null}
diff --git a/src/renderer/src/components/skills/SkillBundleInstallReview.tsx b/src/renderer/src/components/skills/SkillBundleInstallReview.tsx
index 15a7679f3b8..ee3ceb2d90a 100644
--- a/src/renderer/src/components/skills/SkillBundleInstallReview.tsx
+++ b/src/renderer/src/components/skills/SkillBundleInstallReview.tsx
@@ -1,9 +1,10 @@
import type { ReactNode } from 'react'
import { AlertTriangle } from 'lucide-react'
-import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
import type { SkillBundleInstallPreview } from '../../../../shared/skill-bundle-install-contract'
+import { SkillPackageChecklist } from './SkillPackageChecklist'
+import { checklistItemsFromVersion } from './skill-package-checklist-items'
import { translate } from '@/i18n/i18n'
type BundleVersion = SkillCloudVersion & {
@@ -12,14 +13,26 @@ type BundleVersion = SkillCloudVersion & {
const CONFLICT_STATES = new Set(['modified', 'unowned', 'external-link', 'name-collision'])
-function skillCounts(skill: BundleVersion['manifest']['skills'][number]): {
- scripts: number
- executables: number
-} {
- return {
- scripts: skill.files.filter((file) => file.path.startsWith('scripts/')).length,
- executables: skill.files.filter((file) => file.executable).length
+// Why: the row already names the skill, so the note only has to say what
+// installing it would do to the copy already on the machine.
+function destinationNotes(
+ preview: SkillBundleInstallPreview | null
+): ReadonlyMap | undefined {
+ if (!preview) {
+ return undefined
}
+ return new Map(
+ preview.skills.map((skill) => [
+ skill.id,
+ CONFLICT_STATES.has(skill.currentState)
+ ? translate('auto.components.skills.install.rowNeedsDecision', 'Needs a decision')
+ : skill.currentState === 'unchanged'
+ ? translate('auto.components.skills.install.rowInstalled', 'Already installed')
+ : skill.currentState === 'clean-update'
+ ? translate('auto.components.skills.install.rowUpdate', 'Update')
+ : translate('auto.components.skills.install.rowNew', 'New')
+ ])
+ )
}
export function SkillBundleInstallReview(props: {
@@ -34,104 +47,42 @@ export function SkillBundleInstallReview(props: {
children: ReactNode
}): React.JSX.Element {
const { manifest } = props.version
- const selectedSkills = manifest.skills.filter((skill) => props.selectedSkillIds.has(skill.id))
- const selectedFiles = selectedSkills.flatMap((skill) => skill.files)
const conflicts =
props.destinationPreview?.skills.filter((skill) => CONFLICT_STATES.has(skill.currentState)) ??
[]
- const previewSkills = props.destinationPreview?.skills ?? []
const allSelected = props.selectedSkillIds.size === manifest.skills.length
return (
-
+
-
-
-
{manifest.bundleName}
-
- {manifest.description}
+ {manifest.skills.length > 1 ? (
+
+
+ {translate(
+ 'auto.components.skills.install.chooseSkills',
+ 'Choose what to install from this link.'
+ )}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e01',
- 'Immutable version'
- )}
-
-
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e02',
- '{{value0}} skills',
- { value0: selectedSkills.length }
- )}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e03',
- '{{value0}} files',
- { value0: selectedFiles.length }
- )}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e04',
- '{{value0}} scripts',
- {
- value0: selectedFiles.filter((file) => file.path.startsWith('scripts/')).length
- }
- )}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e05',
- '{{value0}} executable',
- { value0: selectedFiles.filter((file) => file.executable).length }
- )}
-
-
- {props.destinationPreview ? (
-
-
+
+ props.onToggleAll(checked === true)}
+ />
{translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e14',
- '{{value0}} new',
- { value0: previewSkills.filter((skill) => skill.currentState === 'missing').length }
+ 'auto.components.skills.SkillBundleInstallReview.01c5a11e0d',
+ 'Select all'
)}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e15',
- '{{value0}} unchanged',
- {
- value0: previewSkills.filter((skill) => skill.currentState === 'unchanged').length
- }
- )}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e16',
- '{{value0}} updates',
- {
- value0: previewSkills.filter((skill) => skill.currentState === 'clean-update')
- .length
- }
- )}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e17',
- '{{value0}} conflicts',
- { value0: conflicts.length }
- )}
-
+
) : null}
-
- {translate('auto.components.skills.SkillBundleInstallReview.01c5a11e06', 'SHA-256')}{' '}
- {manifest.bundleDigest}
-
+
{props.version.releaseNotes.trim() ? (
{translate(
@@ -143,84 +94,12 @@ export function SkillBundleInstallReview(props: {
) : null}
{translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e0a',
- 'Skills contain instructions and may include scripts. Treat them as code from their author.'
+ 'auto.components.skills.install.trustNote',
+ 'Skills are instructions and code from their author. Install what you trust.'
)}
-
-
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e0b',
- 'Skills to install'
- )}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e0c',
- 'Choose any subset from this bundle.'
- )}
-
-
-
- props.onToggleAll(checked === true)}
- />
- {translate('auto.components.skills.SkillBundleInstallReview.01c5a11e0d', 'Select all')}
-
-
-
- {manifest.skills.map((skill) => {
- const counts = skillCounts(skill)
- const state = props.destinationPreview?.skills.find((item) => item.id === skill.id)
- return (
-
- props.onToggleSkill(skill.id, checked === true)}
- />
-
- {skill.name}
-
- {skill.description ||
- translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e0e',
- 'No description'
- )}
-
-
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e0f',
- '{{value0}} files · {{value1}} scripts',
- { value0: skill.files.length, value1: counts.scripts }
- )}
-
-
- {translate(
- 'auto.components.skills.SkillBundleInstallReview.01c5a11e10',
- '{{value0}} executable',
- { value0: counts.executables }
- )}
- {state ? ` · ${state.currentState}` : ''}
-
-
-
- )
- })}
-
-
-
{props.children}
{conflicts.length ? (
diff --git a/src/renderer/src/components/skills/SkillCard.tsx b/src/renderer/src/components/skills/SkillCard.tsx
deleted file mode 100644
index 9ac197c8ec2..00000000000
--- a/src/renderer/src/components/skills/SkillCard.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-import { useId } from 'react'
-import { BookOpen, Clock, FolderOpen, Share2 } from 'lucide-react'
-import { toast } from 'sonner'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent } from '@/components/ui/card'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { Checkbox } from '@/components/ui/checkbox'
-import { translate } from '@/i18n/i18n'
-import type { DiscoveredSkill, SkillProvider } from '../../../../shared/skills'
-import { pluralize, sourceLabels } from './skill-display-labels'
-
-const providerLabels: Record
= {
- codex: 'Codex',
- claude: 'Claude',
- 'agent-skills': 'Agent Skills'
-}
-
-const dateFormatter = new Intl.DateTimeFormat(undefined, {
- month: 'short',
- day: 'numeric',
- hour: 'numeric',
- minute: '2-digit'
-})
-
-function formatUpdatedAt(value: number | null): string {
- return value ? dateFormatter.format(new Date(value)) : 'Unknown'
-}
-
-export function SkillCard({
- skill,
- onShare,
- selected = false,
- selectionMode = false,
- selectionDisabled = false,
- selectionDisabledReason,
- onSelectionChange
-}: {
- skill: DiscoveredSkill
- onShare?: () => void
- selected?: boolean
- selectionMode?: boolean
- selectionDisabled?: boolean
- selectionDisabledReason?: string | null
- onSelectionChange?: (selected: boolean) => void
-}): React.JSX.Element {
- const selectionReasonId = useId()
- const revealSkill = async (): Promise => {
- const result = await window.api.shell.openInFileManager(skill.skillFilePath)
- if (!result.ok) {
- toast.error(
- translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file')
- )
- }
- }
-
- return (
-
-
-
- {selectionMode ? (
-
onSelectionChange?.(value === true)}
- />
- ) : null}
-
-
-
-
-
-
{skill.name}
-
- {skill.installed
- ? translate('auto.components.skills.SkillsPage.0c74e7ff34', 'Installed')
- : translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')}
-
-
- {sourceLabels[skill.sourceKind]}
-
-
- {skill.description ? (
-
- {skill.description}
-
- ) : (
-
- {translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')}
-
- )}
-
-
- {onShare ? (
-
-
-
-
-
-
-
- {translate('auto.components.skills.SkillCard.d25a1b8ae6', 'Share skill')}
-
-
- ) : null}
-
-
- {
- void revealSkill()
- }}
- >
-
-
-
-
- {translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')}
-
-
-
-
-
- {selectionMode && selectionDisabledReason ? (
-
- {selectionDisabledReason}
-
- ) : null}
-
-
-
- {skill.skillFilePath}
-
-
- {skill.providers.map((provider) => (
-
- {providerLabels[provider]}
-
- ))}
-
-
- {skill.sourceLabel}
- {pluralize(skill.fileCount, 'file')}
-
-
- {formatUpdatedAt(skill.updatedAt)}
-
-
-
-
-
- )
-}
diff --git a/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx b/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx
deleted file mode 100644
index 9e7b0aa2605..00000000000
--- a/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-// @vitest-environment happy-dom
-
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
-import { SkillCloudManagementActions } from './SkillCloudManagementActions'
-
-const details: SkillCloudPackageDetails = {
- id: 'pkg_1',
- name: 'private-skill',
- description: 'Private skill',
- createdAt: '2026-08-11T00:00:00.000Z',
- canManage: true,
- versions: [],
- management: {
- shares: [
- {
- id: 'share_1',
- url: 'https://share.test/skills/share/share_1',
- pinnedVersionId: 'ver_1',
- createdAt: '2026-08-11T00:00:00.000Z'
- }
- ]
- }
-}
-
-function setup() {
- const skills = {
- revokeShare: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
- deletePackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
- deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
- }
- const writeClipboardText = vi.fn().mockResolvedValue(undefined)
- const onChanged = vi.fn().mockResolvedValue(undefined)
- const onPackageDeleted = vi.fn()
- Object.defineProperty(window, 'api', {
- configurable: true,
- value: {
- skills,
- ui: { writeClipboardText }
- }
- })
- render(
-
- )
- return { skills, onChanged, onPackageDeleted, writeClipboardText }
-}
-
-afterEach(() => {
- cleanup()
- vi.restoreAllMocks()
- Reflect.deleteProperty(window, 'api')
-})
-
-describe('SkillCloudManagementActions', () => {
- it('requires confirmation before revoking an active link', async () => {
- const { skills } = setup()
- expect(screen.getByText(/leaves installed copies unchanged/)).toBeTruthy()
- expect(screen.queryByText('Access')).toBeNull()
- const unshare = screen.getByRole('button', { name: 'Unshare' })
- unshare.focus()
- fireEvent.click(unshare)
- expect(skills.revokeShare).not.toHaveBeenCalled()
- const confirm = screen.getByRole('button', { name: 'Confirm unshare' })
- expect(document.activeElement).toBe(confirm)
- fireEvent.click(confirm)
-
- await waitFor(() => expect(skills.revokeShare).toHaveBeenCalledWith('share_1'))
- })
-
- it('copies an active unlisted link', async () => {
- const { writeClipboardText } = setup()
- fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
- await waitFor(() =>
- expect(writeClipboardText).toHaveBeenCalledWith('https://share.test/skills/share/share_1')
- )
- })
-
- it('requires confirmation before deleting the selected immutable version', async () => {
- const { skills } = setup()
- fireEvent.click(screen.getByRole('button', { name: 'Delete selected Cloud version' }))
- expect(skills.deletePackageVersion).not.toHaveBeenCalled()
- fireEvent.click(screen.getByRole('button', { name: 'Confirm version deletion' }))
-
- await waitFor(() =>
- expect(skills.deletePackageVersion).toHaveBeenCalledWith({
- packageId: 'pkg_1',
- versionId: 'ver_1'
- })
- )
- })
-
- it('requires confirmation before deleting the Cloud package', async () => {
- const { skills, onChanged, onPackageDeleted } = setup()
- fireEvent.click(screen.getByRole('button', { name: 'Delete Cloud package' }))
- expect(skills.deletePackage).not.toHaveBeenCalled()
- fireEvent.click(screen.getByRole('button', { name: 'Confirm package deletion' }))
-
- await waitFor(() => expect(skills.deletePackage).toHaveBeenCalledWith('pkg_1'))
- expect(onPackageDeleted).toHaveBeenCalledOnce()
- expect(onChanged).not.toHaveBeenCalled()
- })
-})
diff --git a/src/renderer/src/components/skills/SkillCloudManagementActions.tsx b/src/renderer/src/components/skills/SkillCloudManagementActions.tsx
deleted file mode 100644
index 839e954b294..00000000000
--- a/src/renderer/src/components/skills/SkillCloudManagementActions.tsx
+++ /dev/null
@@ -1,232 +0,0 @@
-import { useState } from 'react'
-import { Clipboard, Link2Off, Trash2 } from 'lucide-react'
-import { toast } from 'sonner'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import type {
- SkillCloudOperation,
- SkillCloudPackageDetails
-} from '../../../../shared/skill-cloud-contract'
-import { translate } from '@/i18n/i18n'
-
-type SkillCloudManagement = NonNullable
-type SkillCloudManagementActionsProps = {
- details: SkillCloudPackageDetails
- selectedVersionId: string
- onChanged: () => Promise
- onPackageDeleted: () => void
-}
-
-function operationMessage(operation: SkillCloudOperation): string | null {
- if (operation.status === 'ok') {
- return null
- }
- return operation.status === 'reconnect-required'
- ? 'Reconnect your Orca account and try again.'
- : operation.message
-}
-
-export function SkillCloudManagementActions(
- props: SkillCloudManagementActionsProps
-): React.JSX.Element | null {
- const management = props.details.management
- if (!props.details.canManage || !management) {
- return null
- }
- const stateKey = JSON.stringify([props.details.id, management.shares])
- return
-}
-
-function SkillCloudManagementForm({
- details,
- selectedVersionId,
- onChanged,
- onPackageDeleted,
- management
-}: SkillCloudManagementActionsProps & {
- management: SkillCloudManagement
-}): React.JSX.Element {
- const [busyAction, setBusyAction] = useState(null)
- const [confirmation, setConfirmation] = useState(null)
- const [message, setMessage] = useState(null)
-
- const copyShareLink = async (url?: string): Promise => {
- if (!url) {
- return
- }
- await window.api.ui.writeClipboardText(url)
- toast.success(
- translate(
- 'auto.components.skills.SkillCloudManagementActions.linkCopied',
- 'Share link copied'
- )
- )
- }
-
- const run = async (
- action: string,
- operation: () => Promise>,
- after: () => Promise | void = onChanged
- ): Promise => {
- setBusyAction(action)
- setMessage(null)
- try {
- const result = await operation()
- const failure = operationMessage(result)
- if (failure) {
- setMessage(failure)
- return
- }
- setConfirmation(null)
- try {
- await after()
- } catch {
- setMessage('Cloud change completed, but Orca could not refresh package details.')
- }
- } catch {
- setMessage('Orca could not complete this Cloud change.')
- } finally {
- setBusyAction(null)
- }
- }
-
- return (
-
-
-
- {translate(
- 'auto.components.skills.SkillCloudManagementActions.8cac3b9362',
- 'Active links'
- )}
-
-
- {translate(
- 'auto.components.skills.SkillCloudManagementActions.activeLinkBearerDescription',
- 'Anyone with an active link can inspect and install the skills. Revoking blocks future access but leaves installed copies unchanged.'
- )}
-
- {management.shares.length === 0 ? (
-
- {translate(
- 'auto.components.skills.SkillCloudManagementActions.9e6bd31487',
- 'No active share links.'
- )}
-
- ) : (
- management.shares.map((share) => (
-
-
- {share.url ?? share.id}
-
- {share.url ? (
- void copyShareLink(share.url)}
- >
-
- {translate(
- 'auto.components.skills.SkillCloudManagementActions.copyLink',
- 'Copy link'
- )}
-
- ) : null}
- {
- if (confirmation !== `share:${share.id}`) {
- setConfirmation(`share:${share.id}`)
- return
- }
- void run(`share:${share.id}`, () => window.api.skills.revokeShare(share.id))
- }}
- >
-
- {confirmation === `share:${share.id}`
- ? translate(
- 'auto.components.skills.SkillCloudManagementActions.0ac5c175fd',
- 'Confirm unshare'
- )
- : translate(
- 'auto.components.skills.SkillCloudManagementActions.7a80285dad',
- 'Unshare'
- )}
-
-
- ))
- )}
-
-
-
- {
- if (confirmation !== `version:${selectedVersionId}`) {
- setConfirmation(`version:${selectedVersionId}`)
- return
- }
- void run(`version:${selectedVersionId}`, () =>
- window.api.skills.deletePackageVersion({
- packageId: details.id,
- versionId: selectedVersionId
- })
- )
- }}
- >
-
- {confirmation === `version:${selectedVersionId}`
- ? translate(
- 'auto.components.skills.SkillCloudManagementActions.bbec37d8f8',
- 'Confirm version deletion'
- )
- : translate(
- 'auto.components.skills.SkillCloudManagementActions.6b6adf68c1',
- 'Delete selected Cloud version'
- )}
-
- {
- if (confirmation !== 'package') {
- setConfirmation('package')
- return
- }
- void run('package', () => window.api.skills.deletePackage(details.id), onPackageDeleted)
- }}
- >
-
- {confirmation === 'package'
- ? translate(
- 'auto.components.skills.SkillCloudManagementActions.640bc6b92e',
- 'Confirm package deletion'
- )
- : translate(
- 'auto.components.skills.SkillCloudManagementActions.ed753624c0',
- 'Delete Cloud package'
- )}
-
-
- {message ? (
-
- {message}
-
- ) : null}
-
- )
-}
diff --git a/src/renderer/src/components/skills/SkillDescriptionDisclosure.tsx b/src/renderer/src/components/skills/SkillDescriptionDisclosure.tsx
new file mode 100644
index 00000000000..26e3d1b6b57
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillDescriptionDisclosure.tsx
@@ -0,0 +1,48 @@
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { isLongSkillDescription } from './skill-description-length'
+
+/**
+ * A skill description doubles as its trigger documentation, so it can run for
+ * paragraphs. Show the opening lines and let the reader ask for the rest.
+ */
+export function SkillDescriptionDisclosure({
+ description,
+ className
+}: {
+ description: string | null | undefined
+ className?: string
+}): React.JSX.Element | null {
+ const [expanded, setExpanded] = useState(false)
+ if (!description) {
+ return null
+ }
+ const long = isLongSkillDescription(description)
+ return (
+
+
+ {description}
+
+ {long ? (
+
setExpanded((value) => !value)}
+ >
+ {expanded
+ ? translate('auto.components.skills.description.showLess', 'Show less')
+ : translate('auto.components.skills.description.showMore', 'Show more')}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillDetailDialog.tsx b/src/renderer/src/components/skills/SkillDetailDialog.tsx
new file mode 100644
index 00000000000..7466d71b81b
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillDetailDialog.tsx
@@ -0,0 +1,164 @@
+import { ClipboardCopy, FolderOpen, Share2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import { translate } from '@/i18n/i18n'
+import type { DiscoveredSkill } from '../../../../shared/skills'
+import { fileCountLabel, sourceKindLabel } from './skill-display-labels'
+import { skillAgentLabel } from './skill-agent-filter'
+
+const updatedFormatter = new Intl.DateTimeFormat(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+})
+
+function DetailRow({
+ label,
+ children
+}: {
+ label: string
+ children: React.ReactNode
+}): React.JSX.Element {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ )
+}
+
+/** Agent names come from the roots that reached the file, so a skill shared by
+ * several agents lists all of them rather than the coarse provider tag. */
+function agentNames(
+ skill: DiscoveredSkill,
+ agentByRootPath: ReadonlyMap
+): string[] {
+ const roots = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath]
+ const labels = roots
+ .map((root) => agentByRootPath.get(root))
+ .filter((agent): agent is string => Boolean(agent))
+ .map(skillAgentLabel)
+ return [...new Set(labels)].sort((left, right) => left.localeCompare(right))
+}
+
+export function SkillDetailDialog({
+ skill,
+ agentByRootPath,
+ shareable,
+ onOpenChange,
+ onShare
+}: {
+ skill: DiscoveredSkill | null
+ agentByRootPath: ReadonlyMap
+ shareable: boolean
+ onOpenChange: (open: boolean) => void
+ onShare: () => void
+}): React.JSX.Element | null {
+ if (!skill) {
+ return null
+ }
+
+ const copyPath = async (): Promise => {
+ await window.api.ui.writeClipboardText(skill.skillFilePath)
+ toast.success(translate('auto.components.skills.SkillRow.pathCopied', 'Path copied'))
+ }
+
+ const revealSkill = async (): Promise => {
+ const result = await window.api.shell.openInFileManager(skill.skillFilePath)
+ if (!result.ok) {
+ toast.error(
+ translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file')
+ )
+ }
+ }
+
+ const agents = agentNames(skill, agentByRootPath)
+
+ return (
+
+
+ {/* Why: pr-10 keeps the title clear of the dialog's own close button. */}
+
+
+ {skill.name}
+ {!skill.installed ? (
+
+ {translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')}
+
+ ) : null}
+
+
+ {sourceKindLabel(skill.sourceKind)}
+ {' · '}
+ {skill.sourceLabel}
+
+
+
+
+
+ {skill.description ??
+ translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')}
+
+
+ {agents.length > 0 ? (
+
+ {agents.join(', ')}
+
+ ) : null}
+
+ {fileCountLabel(skill.fileCount)}
+
+
+ {skill.updatedAt
+ ? updatedFormatter.format(new Date(skill.updatedAt))
+ : translate('auto.components.skills.SkillRow.updatedUnknown', 'No date')}
+
+
+ {/* Why: a labelled button beats an icon + tooltip here — the
+ tooltip would cover the very path it names. */}
+
+ {skill.skillFilePath}
+ void copyPath()}
+ >
+
+ {translate('auto.components.skills.SkillDetailDialog.copy', 'Copy')}
+
+
+
+
+
+
+
+ void revealSkill()}>
+
+ {translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')}
+
+
+
+ {translate('auto.components.skills.SkillCard.d25a1b8ae6', 'Share skill')}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillDisclosureTrigger.tsx b/src/renderer/src/components/skills/SkillDisclosureTrigger.tsx
new file mode 100644
index 00000000000..75b47afa83f
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillDisclosureTrigger.tsx
@@ -0,0 +1,23 @@
+import { ChevronRight } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { CollapsibleTrigger } from '@/components/ui/collapsible'
+
+/** Radix stamps `data-state` on the trigger, so the chevron follows the section
+ * without a second copy of its open state in React. */
+export function SkillDisclosureTrigger({ label }: { label: string }): React.JSX.Element {
+ return (
+
+ svg]:px-3`, which a plain
+ // `p-0` cannot override — the chevron would sit indented from the label
+ // it belongs to.
+ className="group h-auto gap-1 p-0 text-xs font-normal text-muted-foreground no-underline hover:text-foreground hover:no-underline has-[>svg]:px-0"
+ >
+
+ {label}
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillInstallAgentPicker.tsx b/src/renderer/src/components/skills/SkillInstallAgentPicker.tsx
new file mode 100644
index 00000000000..76e49896b9b
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillInstallAgentPicker.tsx
@@ -0,0 +1,139 @@
+import { ChevronRight } from 'lucide-react'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+import { Label } from '@/components/ui/label'
+import { translate } from '@/i18n/i18n'
+import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers'
+import {
+ groupSkillInstallProviders,
+ toggledSkillProviderSelection
+} from './skill-install-provider-groups'
+
+function AgentRow({
+ name,
+ directory,
+ checked,
+ disabled,
+ note,
+ onCheckedChange
+}: {
+ name: string
+ directory: string
+ checked: boolean
+ disabled: boolean
+ note: string | null
+ onCheckedChange?: (checked: boolean) => void
+}): React.JSX.Element {
+ return (
+
+
+ onCheckedChange?.(value === true)}
+ />
+ {/* Why: a fixed name column lines the paths up, so the eye reads one
+ list of destinations instead of a ragged edge. */}
+ {name}
+
+ {directory}
+
+ {note ? {note} : null}
+
+
+ )
+}
+
+/**
+ * Mirrors the `skills` CLI: one list, where agents that read the canonical
+ * `.agents/skills` root at this scope are shown already on and locked, because
+ * the canonical copy is written whatever the user picks.
+ */
+export function SkillInstallAgentPicker({
+ scope,
+ selected,
+ detectedAgents,
+ busy,
+ onChange
+}: {
+ scope: 'global' | 'workspace'
+ selected: ReadonlySet
+ detectedAgents: readonly string[] | null
+ busy: boolean
+ onChange: (next: Set) => void
+}): React.JSX.Element {
+ const { canonical, selectable } = groupSkillInstallProviders(scope, detectedAgents)
+ const installed = detectedAgents ? new Set(detectedAgents) : null
+ const canonicalRoot = scope === 'global' ? '~/.agents/skills' : '.agents/skills'
+ const chosen = [
+ ...canonical.map((provider) => provider.displayName),
+ ...selectable
+ .filter((choice) => selected.has(choice.provider.id))
+ .map((choice) => choice.provider.displayName)
+ ]
+ return (
+
+
+ {translate('auto.components.skills.install.agentsLabel', 'Agents')}
+ {/* Why: a full-width row with the chevron trailing, so this disclosure
+ and the skill rows above it open the same way. */}
+
+
+ {chosen.length === 0
+ ? translate('auto.components.skills.install.agentsNone', 'No agents selected')
+ : translate(
+ 'auto.components.skills.install.agentsSelected',
+ 'Installing for: {{value0}}',
+ { value0: chosen.join(', ') }
+ )}
+
+
+
+
+
+
+ {canonical.map((provider) => (
+
+ ))}
+ {selectable.map(({ provider, directory }) => (
+
+ onChange(toggledSkillProviderSelection(selected, provider.id, checked))
+ }
+ />
+ ))}
+
+ {canonical.length > 0 ? (
+
+ {translate(
+ 'auto.components.skills.install.agentsCanonicalNote',
+ '{{value0}} read {{value1}}, which every install writes.',
+ {
+ value0: canonical.map((provider) => provider.displayName).join(', '),
+ value1: canonicalRoot
+ }
+ )}
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx
index 9e90251b539..b6bd43b5707 100644
--- a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx
+++ b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx
@@ -80,6 +80,13 @@ function bundleVersion(): SkillCloudVersion {
}
}
+function detectionApi(agents: string[]) {
+ return {
+ detectAgents: vi.fn().mockResolvedValue(agents),
+ detectRemoteAgents: vi.fn().mockResolvedValue(agents)
+ }
+}
+
function installApi(previewInstall: ReturnType) {
let progressListener: ((progress: SkillInstallProgress) => void) | null = null
return {
@@ -154,7 +161,7 @@ describe('SkillInstallDialog', () => {
render( undefined} />)
await inspectSkill(sharedVersion.description)
- expect(screen.getByRole('heading', { name: longName })).toBeTruthy()
+ expect(screen.getByRole('button', { name: new RegExp(longName) })).toBeTruthy()
expect(screen.queryByText(/Published by Orca user/)).toBeNull()
expect(
screen.getByText(
@@ -164,6 +171,131 @@ describe('SkillInstallDialog', () => {
).toBeTruthy()
})
+ // Why: the picker is the only place a recipient can say "not Claude"; the
+ // request has to carry that choice or the host installs everywhere it can.
+ it('installs only for the agents left checked', async () => {
+ const skills = installApi(
+ vi.fn().mockResolvedValue({
+ status: 'ok',
+ value: {
+ name: 'private-skill',
+ packageDigest: DIGEST,
+ destinationIdentity: 'global:local',
+ currentState: 'missing',
+ providers: []
+ }
+ })
+ )
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills, preflight: detectionApi(['claude', 'codex', 'droid']) }
+ })
+ render( undefined} />)
+ await inspectSkill()
+
+ fireEvent.click(await screen.findByRole('button', { name: /Installing for:/ }))
+ fireEvent.click(screen.getByRole('checkbox', { name: /Claude Code/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
+
+ await waitFor(() => expect(skills.installShare).toHaveBeenCalled())
+ const [request] = skills.installShare.mock.calls[0] as [{ providers: string[] }]
+ expect(request.providers).not.toContain('claude')
+ expect(request.providers).toContain('codex')
+ expect(request.providers).toContain('droid')
+ })
+
+ // Why: checking agents the machine does not have would write placements no
+ // agent reads, and make the user opt out of tools they never installed.
+ it('starts from the agents the target machine has', async () => {
+ const skills = installApi(
+ vi.fn().mockResolvedValue({
+ status: 'ok',
+ value: {
+ name: 'private-skill',
+ packageDigest: DIGEST,
+ destinationIdentity: 'global:local',
+ currentState: 'missing',
+ providers: []
+ }
+ })
+ )
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills, preflight: detectionApi(['codex']) }
+ })
+ render( undefined} />)
+ await inspectSkill()
+ await screen.findByRole('button', { name: 'Installing for: Codex' })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
+ await waitFor(() => expect(skills.installShare).toHaveBeenCalled())
+ const [request] = skills.installShare.mock.calls[0] as [{ providers: string[] }]
+ expect(request.providers).toEqual(['codex'])
+ })
+
+ // Why: a skill description doubles as its trigger documentation and can run
+ // for paragraphs; the row shows one line until the reader opens it.
+ it('unclamps a long description when the row is opened', async () => {
+ const sharedVersion = version()
+ sharedVersion.description = `Use the public CLI. ${'trigger words.'.repeat(20)}`
+ const skills = installApi(vi.fn())
+ skills.resolveShare.mockResolvedValue({
+ status: 'ok',
+ value: { id: 'share_1', version: sharedVersion }
+ })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills, preflight: detectionApi(['codex']) }
+ })
+ render( undefined} />)
+ await inspectSkill(sharedVersion.description)
+
+ const description = screen.getByText(sharedVersion.description)
+ expect(description.className).toContain('line-clamp-1')
+ expect(description.className).toContain('group-data-[state=open]/row:line-clamp-none')
+ })
+
+ // Why: "view the full skill contents" is the point of the row — the file list
+ // is the only place a recipient sees what lands on their machine.
+ it('lists the files a skill would write when its row is expanded', async () => {
+ const skills = installApi(vi.fn())
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills, preflight: detectionApi(['codex']) }
+ })
+ render( undefined} />)
+ await inspectSkill()
+
+ expect(screen.queryByText('SKILL.md')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: /private-skill/ }))
+ expect(screen.getByText('SKILL.md')).toBeTruthy()
+ })
+
+ // Why: a recipient cannot compare a digest against anything, and the badge
+ // language ("Immutable version") explained nothing. Both left the review.
+ it.each([
+ ['skill', () => version(), 'a'],
+ ['bundle', () => bundleVersion(), 'c']
+ ])('keeps package plumbing out of the %s review', async (_kind, build, char) => {
+ const skills = installApi(vi.fn())
+ skills.resolveShare.mockResolvedValue({
+ status: 'ok',
+ value: { id: 'share_1', version: build() }
+ })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills, preflight: detectionApi(['codex']) }
+ })
+ render( undefined} />)
+ await inspectSkill('skills' in build().manifest ? 'alpha description' : build().description)
+
+ expect(screen.queryByText(/SHA-256/)).toBeNull()
+ expect(screen.queryByText(char.repeat(64))).toBeNull()
+ expect(screen.queryByText('Immutable version')).toBeNull()
+ expect(screen.queryByText('0 scripts')).toBeNull()
+ expect(screen.getAllByText(/No scripts or executables/).length).toBeGreaterThan(0)
+ })
+
it('focuses the link first and programmatically names destination controls', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
@@ -179,16 +311,26 @@ describe('SkillInstallDialog', () => {
}
})
)
- Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills, preflight: detectionApi(['codex']) }
+ })
render( )
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Orca skill link' }))
+ // Why: the submit sits in the footer beside Close, so Enter in the field is
+ // the keyboard path rather than tabbing past the back-out action.
+ const footerButtons = screen
+ .getAllByRole('button')
+ .filter((button) => button.getAttribute('data-slot') !== 'dialog-close')
+ expect(footerButtons.map((button) => button.textContent?.trim())).toEqual([
+ 'Close',
+ 'Inspect skill'
+ ])
await user.type(
screen.getByRole('textbox', { name: 'Orca skill link' }),
'https://app.orca.dev/skills/share/share_1'
)
- await user.tab()
- expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Inspect skill' }))
await user.keyboard('{Enter}')
await screen.findByText('A private skill')
expect(screen.getByRole('combobox', { name: 'Machine' })).toBeTruthy()
@@ -251,12 +393,15 @@ describe('SkillInstallDialog', () => {
}
})
})
- Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills, preflight: detectionApi(['codex']) }
+ })
render( undefined} />)
- await inspectSkill('Two private skills')
+ await inspectSkill('alpha description')
fireEvent.click(screen.getByRole('checkbox', { name: /alpha/ }))
- fireEvent.click(screen.getByRole('button', { name: 'Install 1 skills' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Install 1 skill' }))
await screen.findByText('Skills installed and verified.')
expect(skills.installBundleShare).toHaveBeenCalledWith(
@@ -298,7 +443,7 @@ describe('SkillInstallDialog', () => {
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- await inspectSkill('Two private skills')
+ await inspectSkill('alpha description')
fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' }))
await screen.findByText('Local copies need a decision')
diff --git a/src/renderer/src/components/skills/SkillInstallDialog.tsx b/src/renderer/src/components/skills/SkillInstallDialog.tsx
index 072518a2fb1..e972acc99f1 100644
--- a/src/renderer/src/components/skills/SkillInstallDialog.tsx
+++ b/src/renderer/src/components/skills/SkillInstallDialog.tsx
@@ -1,10 +1,9 @@
-import { useMemo, useState } from 'react'
-import { Download, Loader2 } from 'lucide-react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Download, Loader2, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
- DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
@@ -20,6 +19,9 @@ import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
import { isSkillBundleVersion, type ResolvedSkillShare } from './skill-share-version-summary'
import { SkillBundleInstallFlow } from './SkillBundleInstallFlow'
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
+import { defaultSelectedSkillProviders } from './skill-install-provider-groups'
+import { useSkillInstallDetectedAgents } from './use-skill-install-detected-agents'
+import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers'
import {
SkillInstallOutcome,
SkillInstallReview,
@@ -49,16 +51,25 @@ export function SkillInstallDialog({
const [preview, setPreview] = useState(null)
const [environmentId, setEnvironmentId] = useState('local')
const [scope, setScope] = useState<'global' | 'workspace'>('global')
+ // Why: null means "follow detection"; storing the derived default instead
+ // would freeze the picker on whichever machine was selected first.
+ const [providerChoice, setProviderChoice] = useState | null>(null)
const [workspace, setWorkspace] = useState('')
const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>(
null
)
const [busy, setBusy] = useState(false)
const [bundleBusy, setBundleBusy] = useState(false)
+ const autoResolved = useRef(false)
const [error, setError] = useState(null)
const [result, setResult] = useState(null)
const [destinationPreview, setDestinationPreview] = useState(null)
const installProgress = useSkillInstallProgress()
+ const detectedAgents = useSkillInstallDetectedAgents({
+ environmentId,
+ wslDistro: executionTarget?.distro ?? null
+ })
+ const providers = providerChoice ?? defaultSelectedSkillProviders(detectedAgents)
const workspaceChoices = useMemo(
() => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }),
@@ -74,8 +85,8 @@ export function SkillInstallDialog({
[sshConnectionStates, sshTargetLabels]
)
- const inspect = async (): Promise => {
- const shareId = parseSkillShareId(link)
+ const resolveLink = useCallback(async (value: string): Promise => {
+ const shareId = parseSkillShareId(value)
if (!shareId) {
setError('Enter an Orca skill share link.')
return
@@ -100,7 +111,23 @@ export function SkillInstallDialog({
} finally {
setBusy(false)
}
- }
+ }, [])
+
+ const inspect = (): Promise => resolveLink(link)
+
+ /** True from the moment a prefilled link opens the dialog until it resolves
+ * or fails, so the paste step never appears for a caller-supplied link. */
+ const resolvingInitialLink = Boolean(initialLink) && !preview && !error
+
+ // Why: arriving with a link already in hand means the paste step is busywork,
+ // so the dialog opens on the review screen instead of asking for a click.
+ useEffect(() => {
+ if (!open || !initialLink || autoResolved.current) {
+ return
+ }
+ autoResolved.current = true
+ void resolveLink(initialLink)
+ }, [initialLink, open, resolveLink])
const install = async (discardLocal = false): Promise => {
if (!preview) {
@@ -163,6 +190,7 @@ export function SkillInstallDialog({
operationId,
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
destination,
+ providers: [...providers],
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
})
if (operation.status === 'unsupported') {
@@ -219,7 +247,15 @@ export function SkillInstallDialog({
return (
!next && !busy && !bundleBusy && close()}>
-
+ {/* Why: DialogContent is a grid, so a child that cannot wrap (a digest, a
+ path, a long name) raises the grid's minimum width and the whole dialog
+ scrolls sideways with its content clipped. min-w-0 lets children reflow
+ at the dialog width; overflow-x-hidden makes sideways scroll impossible
+ even if some future child still refuses to shrink. */}
+
{bundleVersion
@@ -232,18 +268,20 @@ export function SkillInstallDialog({
'Install shared skill'
)}
-
- {translate(
- 'auto.components.skills.SkillInstallDialog.4a00d133c5',
- 'Orca verifies access and package identity before changing the selected machine.'
- )}
-
+ {/* Why: the title plus what is on screen already says this; the old
+ line was package-identity jargon a recipient cannot act on. */}
- {!preview ? (
+ {resolvingInitialLink ? (
+ // Why: the paste form would flash for the length of the round-trip
+ // before being replaced by a review the caller already asked for.
+
+
+ {translate('auto.components.skills.SkillInstallDialog.opening', 'Opening this link…')}
+
+ ) : !preview ? (
void inspect()}
/>
@@ -285,6 +323,10 @@ export function SkillInstallDialog({
setExecutionTarget(value)
setDestinationPreview(null)
}}
+ providers={providers}
+ detectedAgents={detectedAgents}
+ onProvidersChange={setProviderChoice}
+ busy={busy}
runtimeEnvironments={runtimeEnvironments}
runtimeStatus={runtimeStatus}
sshConnections={sshConnections}
@@ -308,6 +350,24 @@ export function SkillInstallDialog({
{translate('auto.components.skills.SkillInstallDialog.d198ec91e5', 'Close')}
+ {!preview && !resolvingInitialLink ? (
+ void inspect()}>
+ {busy ? (
+
+ ) : (
+
+ )}
+ {busy
+ ? translate(
+ 'auto.components.skills.SkillInstallReviewContent.69236de8d6',
+ 'Checking…'
+ )
+ : translate(
+ 'auto.components.skills.SkillInstallReviewContent.157de228b4',
+ 'Inspect skill'
+ )}
+
+ ) : null}
{busy && installProgress.activeOperationId ? (
void cancelInstall()}>
{translate(
diff --git a/src/renderer/src/components/skills/SkillInstallMachineSelect.tsx b/src/renderer/src/components/skills/SkillInstallMachineSelect.tsx
new file mode 100644
index 00000000000..6083f504c7a
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillInstallMachineSelect.tsx
@@ -0,0 +1,38 @@
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select'
+
+export function SkillInstallMachineSelect(props: {
+ value: string
+ onChange(value: string): void
+ localLabel: string
+ sshLabel: string
+ disconnectedLabel: string
+ environments: { id: string; name: string }[]
+ sshTargets: { id: string; label: string; connected: boolean }[]
+}): React.JSX.Element {
+ return (
+
+
+
+
+
+ {props.localLabel}
+ {props.environments.map((environment) => (
+
+ {environment.name}
+
+ ))}
+ {props.sshTargets.map((target) => (
+
+ {target.label} {target.connected ? props.sshLabel : props.disconnectedLabel}
+
+ ))}
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx b/src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx
index 3b63a837a45..784cf0d8463 100644
--- a/src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx
+++ b/src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx
@@ -155,10 +155,19 @@ function bundleInstall(name: string, state: ManagedSkillInstall['state'] = 'unch
return { ...install('ver_1'), name, bundleDigest: DIGEST, state }
}
-async function selectInstall(versionId: string): Promise {
- await screen.findByText(`global · ${versionId}`)
- fireEvent.click(screen.getByRole('button', { name: new RegExp(`private-skill.*${versionId}`) }))
- await screen.findByText(`Installed version ${versionId}`)
+const ROW_ACTION = /Reinstall|Use this version/
+
+function dateLabel(iso: string): string {
+ return new Intl.DateTimeFormat(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ }).format(new Date(iso))
+}
+
+async function openInstall(name = 'private-skill'): Promise {
+ fireEvent.click(await screen.findByRole('button', { name: new RegExp(name) }))
+ await screen.findByRole('button', { name: ROW_ACTION })
}
beforeEach(() => {
@@ -205,17 +214,89 @@ describe('SkillInstallManagementDialog', () => {
let resolveLocal: ((value: unknown) => void) | undefined
skills.listManagedInstalls
.mockImplementationOnce(() => new Promise((resolve) => (resolveLocal = resolve)) as never)
- .mockResolvedValueOnce({ status: 'ok', value: [install('remote_version')] })
+ .mockResolvedValueOnce({
+ status: 'ok',
+ value: [{ ...install('remote_version'), name: 'remote-skill' }]
+ })
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'Remote' }))
- await screen.findByText('global · remote_version')
+ await screen.findByText('remote-skill · Everywhere')
resolveLocal?.({ status: 'ok', value: [install('local_version')] })
- await waitFor(() => expect(screen.queryByText('global · local_version')).toBeNull())
- expect(screen.getByText('global · remote_version')).toBeTruthy()
+ await waitFor(() => expect(screen.queryByText('private-skill · Everywhere')).toBeNull())
+ expect(screen.getByText('remote-skill · Everywhere')).toBeTruthy()
+ })
+
+ it('ignores stale package details after switching machines', async () => {
+ useAppStore.setState({
+ runtimeEnvironments: [
+ {
+ id: 'remote_1',
+ name: 'Remote',
+ createdAt: 1,
+ updatedAt: 1,
+ lastUsedAt: null,
+ runtimeId: null,
+ endpoints: [
+ {
+ id: 'remote_ws',
+ kind: 'websocket',
+ label: 'WebSocket',
+ endpoint: 'ws://remote.invalid'
+ }
+ ],
+ preferredEndpointId: 'remote_ws'
+ }
+ ]
+ })
+ const remoteInstall = {
+ ...install('remote_version'),
+ name: 'remote-skill',
+ packageId: 'pkg_remote'
+ }
+ const skills = skillsApi(install('local_version'), [
+ version('local_version', '2026-08-12T00:00:00.000Z')
+ ])
+ let resolveLocalDetails: ((value: unknown) => void) | undefined
+ skills.listManagedInstalls
+ .mockResolvedValueOnce({ status: 'ok', value: [install('local_version')] })
+ .mockResolvedValueOnce({ status: 'ok', value: [remoteInstall] })
+ skills.getPackage
+ .mockImplementationOnce(
+ () => new Promise((resolve) => (resolveLocalDetails = resolve)) as never
+ )
+ .mockResolvedValueOnce({
+ status: 'ok',
+ value: {
+ ...packageDetails([version('remote_version', '2026-08-13T00:00:00.000Z')]),
+ id: 'pkg_remote',
+ name: 'remote-skill'
+ }
+ })
+ Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
+ render( undefined} />)
+
+ await screen.findByText('private-skill · Everywhere')
+ fireEvent.click(screen.getByRole('button', { name: /private-skill/ }))
+ fireEvent.click(screen.getByRole('combobox'))
+ fireEvent.click(screen.getByRole('option', { name: 'Remote' }))
+ fireEvent.click(await screen.findByRole('button', { name: /remote-skill/ }))
+ const remoteLabel = `${dateLabel('2026-08-13T00:00:00.000Z')} (installed)`
+ await screen.findByText(remoteLabel)
+
+ resolveLocalDetails?.({
+ status: 'ok',
+ value: packageDetails([version('local_version', '2026-08-12T00:00:00.000Z')])
+ })
+ // Why: the stale local details would repaint the version picker with the
+ // local build's date.
+ await waitFor(() =>
+ expect(screen.queryByText(dateLabel('2026-08-12T00:00:00.000Z'))).toBeNull()
+ )
+ expect(screen.getByText(remoteLabel)).toBeTruthy()
})
it('updates an installed skill to the newest immutable version', async () => {
@@ -227,9 +308,9 @@ describe('SkillInstallManagementDialog', () => {
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, changed)
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- await selectInstall('ver_1')
+ await openInstall()
- fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Use this version' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledOnce())
expect(skills.installPackageVersion).toHaveBeenCalledWith(
@@ -258,9 +339,9 @@ describe('SkillInstallManagementDialog', () => {
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, changed)
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- await selectInstall('ver_1')
+ await openInstall()
- fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Use this version' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledOnce())
expect(changed).not.toHaveBeenCalled()
@@ -274,9 +355,9 @@ describe('SkillInstallManagementDialog', () => {
])
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- await selectInstall('ver_2')
+ await openInstall()
- fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Use this version' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledOnce())
expect(skills.installPackageVersion).toHaveBeenCalledWith(
@@ -288,7 +369,7 @@ describe('SkillInstallManagementDialog', () => {
const skills = skillsApi(install('ver_2'), [version('ver_2', '2026-08-12T00:00:00.000Z')])
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- await selectInstall('ver_2')
+ await openInstall()
const remove = screen.getByRole('button', { name: 'Remove' })
remove.focus()
@@ -304,31 +385,20 @@ describe('SkillInstallManagementDialog', () => {
)
})
- it('keeps the local install visible after deleting its Cloud package', async () => {
- const versions = [version('ver_2', '2026-08-12T00:00:00.000Z')]
- const details: SkillCloudPackageDetails = {
- ...packageDetails(versions),
- management: {
- shares: []
- }
- }
- const skills = skillsApi(install('ver_2'), versions, details)
- Object.defineProperty(window, 'api', {
- configurable: true,
- value: {
- skills,
- orcaProfiles: { authStatus: vi.fn().mockResolvedValue({ cloud: undefined }) }
- }
+ it('allows removal when Cloud package history is unavailable', async () => {
+ const skills = skillsApi(install('ver_2'), [version('ver_2', '2026-08-12T00:00:00.000Z')])
+ skills.getPackage.mockResolvedValue({
+ status: 'unsupported',
+ message: 'Package history is unavailable.'
})
+ Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- await selectInstall('ver_2')
- fireEvent.click(screen.getByRole('button', { name: 'Delete Cloud package' }))
- fireEvent.click(screen.getByRole('button', { name: 'Confirm package deletion' }))
+ fireEvent.click(await screen.findByRole('button', { name: /private-skill/ }))
+ fireEvent.click(await screen.findByRole('button', { name: 'Remove' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm remove' }))
- await screen.findByText('Cloud package deleted. The installed copy remains on this machine.')
- expect(skills.deletePackage).toHaveBeenCalledWith('pkg_1')
- expect(screen.getByText('global · ver_2')).toBeTruthy()
+ await waitFor(() => expect(skills.removeInstall).toHaveBeenCalledOnce())
})
it('retries incomplete coverage for the currently installed version', async () => {
@@ -356,11 +426,11 @@ describe('SkillInstallManagementDialog', () => {
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- await selectInstall('ver_1')
+ await openInstall()
- fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
- await screen.findByRole('button', { name: 'Retry incomplete coverage' })
- fireEvent.click(screen.getByRole('button', { name: 'Retry incomplete coverage' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Use this version' }))
+ await screen.findByRole('button', { name: 'Finish installing' })
+ fireEvent.click(screen.getByRole('button', { name: 'Finish installing' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledTimes(2))
expect(skills.installPackageVersion.mock.calls[1]?.[0]).toEqual(
@@ -375,9 +445,8 @@ describe('SkillInstallManagementDialog', () => {
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- fireEvent.click(await screen.findByRole('button', { name: /2 skill bundle.*ver_1/ }))
- await screen.findByText('team-skills')
- fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' }))
+ fireEvent.click(await screen.findByRole('button', { name: /alpha-skill \+1/ }))
+ fireEvent.click(await screen.findByRole('button', { name: 'Use this version' }))
await waitFor(() => expect(skills.installBundlePackageVersion).toHaveBeenCalledOnce())
expect(skills.installBundlePackageVersion).toHaveBeenCalledWith(
@@ -410,8 +479,8 @@ describe('SkillInstallManagementDialog', () => {
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( )
- fireEvent.click(await screen.findByRole('button', { name: /2 skill bundle.*ver_1/ }))
- fireEvent.click(await screen.findByRole('button', { name: 'Install on another machine' }))
+ fireEvent.click(await screen.findByRole('button', { name: /alpha-skill \+1/ }))
+ fireEvent.click(await screen.findByRole('button', { name: /Install elsewhere/ }))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(useAppStore.getState().pendingSkillShareId).toBe('share_bundle')
@@ -444,10 +513,9 @@ describe('SkillInstallManagementDialog', () => {
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render( undefined} />)
- fireEvent.click(await screen.findByRole('button', { name: /2 skill bundle.*ver_1/ }))
- await screen.findByText('team-skills')
- fireEvent.click(screen.getByRole('button', { name: 'Remove 2 skills' }))
- fireEvent.click(screen.getByRole('button', { name: 'Confirm remove 2 skills' }))
+ fireEvent.click(await screen.findByRole('button', { name: /alpha-skill \+1/ }))
+ fireEvent.click(await screen.findByRole('button', { name: 'Remove' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm remove' }))
await screen.findByText('1 removed · 1 modified skill preserved.')
expect(skills.removeInstall).toHaveBeenCalledTimes(2)
@@ -459,4 +527,32 @@ describe('SkillInstallManagementDialog', () => {
)
expect(skills.removeInstall.mock.calls[1]?.[0]).not.toHaveProperty('conflictResolution')
})
+
+ it('discards changes and removes every skill in a mixed-state bundle', async () => {
+ const installed = [bundleInstall('alpha-skill'), bundleInstall('beta-skill', 'modified')]
+ const skills = skillsApi(installed, [bundleVersion('ver_1', ['alpha-skill', 'beta-skill'])])
+ Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
+ render( undefined} />)
+
+ fireEvent.click(await screen.findByRole('button', { name: /alpha-skill \+1/ }))
+ fireEvent.click(await screen.findByRole('button', { name: 'Discard my edits and remove' }))
+
+ await waitFor(() => expect(skills.removeInstall).toHaveBeenCalledTimes(2))
+ expect(skills.removeInstall.mock.calls).toEqual(
+ expect.arrayContaining([
+ [
+ expect.objectContaining({
+ name: 'alpha-skill',
+ conflictResolution: 'replace-and-discard-local'
+ })
+ ],
+ [
+ expect.objectContaining({
+ name: 'beta-skill',
+ conflictResolution: 'replace-and-discard-local'
+ })
+ ]
+ ])
+ )
+ })
})
diff --git a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx
index 7a5f3d38a19..47d7518ccdd 100644
--- a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx
+++ b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx
@@ -9,13 +9,6 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue
-} from '@/components/ui/select'
import { useAppStore } from '@/store'
import type {
ManagedSkillInstall,
@@ -27,14 +20,14 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkil
import { skillInstallResultLabel } from './skill-install-result-label'
import { skillInstallManagementCopy } from './skill-install-management-copy'
import { useSkillInstallProgress } from './skill-install-progress-state'
-import { SkillManagedInstallList } from './SkillManagedInstallList'
-import { SkillManagedInstallDetails } from './SkillManagedInstallDetails'
+import { SkillManagedInstallRow } from './SkillManagedInstallRow'
import { summarizeManagedSkillRemoval } from './skill-managed-removal-summary'
-import { retainManagedSkillVersion } from './skill-managed-version-selection'
import {
groupManagedSkillInstalls,
type SkillManagedInstallGroup
} from './skill-managed-install-groups'
+import { SkillInstallMachineSelect } from './SkillInstallMachineSelect'
+import { SkillInstallManagementStatus } from './SkillInstallManagementStatus'
export function SkillInstallManagementDialog({
open,
@@ -60,6 +53,7 @@ export function SkillInstallManagementDialog({
const [notice, setNotice] = useState(null)
const installProgress = useSkillInstallProgress()
const loadGeneration = useRef(0)
+ const detailGeneration = useRef(0)
const groups = useMemo(() => groupManagedSkillInstalls(installs), [installs])
const selected = useMemo(
@@ -70,6 +64,7 @@ export function SkillInstallManagementDialog({
const load = useCallback(async (): Promise => {
const generation = ++loadGeneration.current
+ detailGeneration.current += 1
if (!open) {
return
}
@@ -108,7 +103,9 @@ export function SkillInstallManagementDialog({
}, [load])
const selectInstall = async (group: SkillManagedInstallGroup): Promise => {
+ const generation = ++detailGeneration.current
setSelectedKey(group.key)
+ setDetails(null)
setBusy(true)
setError(null)
setNotice(null)
@@ -117,6 +114,9 @@ export function SkillInstallManagementDialog({
setConfirmRemove(false)
try {
const operation = await window.api.skills.getPackage(group.packageId)
+ if (generation !== detailGeneration.current) {
+ return
+ }
if (operation.status !== 'ok') {
setError(
operation.status === 'reconnect-required'
@@ -128,29 +128,25 @@ export function SkillInstallManagementDialog({
setDetails(operation.value)
setVersionId(operation.value.versions[0]?.versionId ?? group.versionId)
} catch (cause) {
+ if (generation !== detailGeneration.current) {
+ return
+ }
console.warn('[skills] package history failed:', cause)
setError('Version history is unavailable for this skill.')
} finally {
- setBusy(false)
+ if (generation === detailGeneration.current) {
+ setBusy(false)
+ }
}
}
- const refreshPackageDetails = async (): Promise => {
- if (!selected) {
- return
- }
- const operation = await window.api.skills.getPackage(selected.packageId)
- if (operation.status !== 'ok') {
- throw new Error(operation.status)
- }
- setDetails(operation.value)
- setVersionId((current) => retainManagedSkillVersion(operation.value.versions, current))
- }
-
- const packageDeleted = (): void => {
+ const collapse = (): void => {
+ detailGeneration.current += 1
+ setSelectedKey('')
setDetails(null)
- setVersionId('')
- setNotice('Cloud package deleted. The installed copy remains on this machine.')
+ setResult(null)
+ setBundleResult(null)
+ setConfirmRemove(false)
}
const installVersion = async (discardLocal = false): Promise => {
@@ -183,6 +179,7 @@ export function SkillInstallManagementDialog({
? {}
: { environmentId }),
selectedSkillIds: selectedSkills.map((skill) => skill.id),
+ ...(selectedInstall.providers ? { providers: selectedInstall.providers } : {}),
destination: selected.destination,
...(discardLocal
? {
@@ -216,6 +213,7 @@ export function SkillInstallManagementDialog({
operationId,
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
destination: selected.destination,
+ ...(selectedInstall.providers ? { providers: selectedInstall.providers } : {}),
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
})
if (operation.status !== 'ok') {
@@ -267,9 +265,7 @@ export function SkillInstallManagementDialog({
setError(null)
setNotice(null)
try {
- const targets = discardLocal
- ? selected.installs.filter((install) => install.state === 'modified')
- : selected.installs
+ const targets = selected.installs
const operations = await Promise.all(
targets.map((install) =>
window.api.skills.removeInstall({
@@ -305,6 +301,8 @@ export function SkillInstallManagementDialog({
}
const close = (): void => {
+ loadGeneration.current += 1
+ detailGeneration.current += 1
setSelectedKey('')
setDetails(null)
setError(null)
@@ -319,34 +317,24 @@ export function SkillInstallManagementDialog({
return (
!next && !busy && close()}>
-
+
{copy.title}
{copy.description}
-
-
-
-
-
- {copy.localMachine}
- {runtimeEnvironments.map((environment) => (
-
- {environment.name}
-
- ))}
- {[...sshTargetLabels.entries()].map(([id, label]) => (
-
- {label}{' '}
- {sshConnectionStates.get(id)?.status === 'connected' ? copy.ssh : copy.disconnected}
-
- ))}
-
-
+ ({
+ id: `ssh:${id}`,
+ label,
+ connected: sshConnectionStates.get(id)?.status === 'connected'
+ }))}
+ />
{busy && installs.length === 0 ? : null}
{!busy && installs.length === 0 ? (
@@ -354,56 +342,40 @@ export function SkillInstallManagementDialog({
{copy.noInstalls}
) : null}
- void selectInstall(group)}
+ {groups.length > 0 ? (
+
+ {groups.map((group) => (
+ (next ? void selectInstall(group) : collapse())}
+ onVersionChange={setVersionId}
+ onInstall={(discardLocal) => void installVersion(discardLocal)}
+ onCancelInstall={() => void cancelInstall()}
+ onSendToMachine={(shareId) => {
+ close()
+ useAppStore.getState().openSkillShare(shareId)
+ }}
+ onRemove={(discardLocal) => void remove(discardLocal)}
+ />
+ ))}
+
+ ) : null}
+
-
- {selected && details ? (
- void installVersion(discardLocal)}
- onCancelInstall={() => void cancelInstall()}
- onInstallAnotherMachine={(shareId) => {
- close()
- useAppStore.getState().openSkillShare(shareId)
- }}
- onRemove={(discardLocal) => void remove(discardLocal)}
- onCloudChanged={refreshPackageDetails}
- onPackageDeleted={packageDeleted}
- />
- ) : null}
- {result ? (
-
- {skillInstallResultLabel(result)}
-
- ) : null}
- {installProgress.phaseLabel ? (
-
- {installProgress.phaseLabel}
-
- ) : null}
- {error ? (
-
- {error}
-
- ) : null}
- {notice ? (
-
- {notice}
-
- ) : null}
{copy.close}
diff --git a/src/renderer/src/components/skills/SkillInstallManagementStatus.tsx b/src/renderer/src/components/skills/SkillInstallManagementStatus.tsx
new file mode 100644
index 00000000000..fd083218f35
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillInstallManagementStatus.tsx
@@ -0,0 +1,31 @@
+export function SkillInstallManagementStatus(props: {
+ resultLabel: string | null
+ progressLabel: string | null
+ error: string | null
+ notice: string | null
+}): React.JSX.Element {
+ return (
+ <>
+ {props.resultLabel ? (
+
+ {props.resultLabel}
+
+ ) : null}
+ {props.progressLabel ? (
+
+ {props.progressLabel}
+
+ ) : null}
+ {props.error ? (
+
+ {props.error}
+
+ ) : null}
+ {props.notice ? (
+
+ {props.notice}
+
+ ) : null}
+ >
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillInstallReviewContent.tsx b/src/renderer/src/components/skills/SkillInstallReviewContent.tsx
index b67ae6f69e3..4e118892bfa 100644
--- a/src/renderer/src/components/skills/SkillInstallReviewContent.tsx
+++ b/src/renderer/src/components/skills/SkillInstallReviewContent.tsx
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
-import { AlertTriangle, Check, Loader2, ShieldCheck } from 'lucide-react'
-import { Badge } from '@/components/ui/badge'
+import { AlertTriangle, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -8,63 +7,54 @@ import type {
SkillInstallPreview,
SkillInstallResult
} from '../../../../shared/skill-install-contract'
+import { SkillPackageChecklist } from './SkillPackageChecklist'
+import { checklistItemsFromVersion } from './skill-package-checklist-items'
import { skillInstallResultLabel } from './skill-install-result-label'
-import { summarizeSkillShareVersion, type ResolvedSkillShare } from './skill-share-version-summary'
+import type { ResolvedSkillShare } from './skill-share-version-summary'
import { translate } from '@/i18n/i18n'
+/** Why: the submit lives in the dialog footer beside Close, so this is only the
+ * field; Enter still submits through the form. */
export function SkillShareLinkInputForm({
link,
- busy,
onLinkChange,
onSubmit
}: {
link: string
- busy: boolean
onLinkChange: (link: string) => void
onSubmit: () => void
}): React.JSX.Element {
return (
)
}
@@ -136,7 +126,6 @@ export function SkillInstallReview({
children: ReactNode
}): React.JSX.Element {
const version = preview.version
- const versionSummary = summarizeSkillShareVersion(version)
const hasConflict =
result?.status === 'conflict' ||
(destinationPreview &&
@@ -146,38 +135,11 @@ export function SkillInstallReview({
return (
-
-
-
{version.name}
-
- {version.description}
-
-
-
- {translate(
- 'auto.components.skills.SkillInstallReviewContent.8f9833d509',
- 'Immutable version'
- )}
-
-
-
-
- {versionSummary.fileCount}{' '}
- {translate('auto.components.skills.SkillInstallReviewContent.fab8fce842', 'files')}
-
-
- {versionSummary.scriptCount}{' '}
- {translate('auto.components.skills.SkillInstallReviewContent.87137bcb8d', 'scripts')}
-
-
- {versionSummary.executableCount}{' '}
- {translate('auto.components.skills.SkillInstallReviewContent.3d8421ca2f', 'executable')}
-
-
-
- {translate('auto.components.skills.SkillInstallReviewContent.f72ee4022a', 'SHA-256')}{' '}
- {version.packageDigest}
-
+
{version.releaseNotes.trim() ? (
{translate(
@@ -189,8 +151,8 @@ export function SkillInstallReview({
) : null}
{translate(
- 'auto.components.skills.SkillInstallReviewContent.98ed90e523',
- 'A skill contains instructions and may include scripts. Treat it as code from its author.'
+ 'auto.components.skills.install.trustNote',
+ 'Skills are instructions and code from their author. Install what you trust.'
)}
diff --git a/src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx b/src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx
index 3292cc17950..a5d6d682f44 100644
--- a/src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx
+++ b/src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx
@@ -4,6 +4,7 @@ import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
+import { defaultSelectedSkillProviders } from './skill-install-provider-groups'
const callbacks = {
onEnvironmentChange: vi.fn(),
@@ -34,7 +35,10 @@ describe('SkillInstallTargetFields', () => {
runtimeEnvironments: [],
runtimeStatus: new Map(),
sshConnections: [],
- workspaceChoices: [{ id: 'folder_1', label: longWorkspace, kind: 'folder' as const }]
+ workspaceChoices: [{ id: 'folder_1', label: longWorkspace, kind: 'folder' as const }],
+ providers: defaultSelectedSkillProviders(null),
+ detectedAgents: null,
+ onProvidersChange: vi.fn()
}
const view = render(
)
diff --git a/src/renderer/src/components/skills/SkillInstallTargetFields.tsx b/src/renderer/src/components/skills/SkillInstallTargetFields.tsx
index 4d070e93550..f92f7459895 100644
--- a/src/renderer/src/components/skills/SkillInstallTargetFields.tsx
+++ b/src/renderer/src/components/skills/SkillInstallTargetFields.tsx
@@ -8,6 +8,8 @@ import {
SelectValue
} from '@/components/ui/select'
import { SKILL_INSTALL_CAPABILITY } from '../../../../shared/skill-install-capability'
+import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers'
+import { SkillInstallAgentPicker } from './SkillInstallAgentPicker'
import type { SkillInstallWorkspaceChoice } from './skill-install-workspace-choices'
import { translate } from '@/i18n/i18n'
@@ -25,6 +27,10 @@ export function SkillInstallTargetFields(props: {
sshConnections: { id: string; label: string; connected: boolean }[]
workspaceChoices: SkillInstallWorkspaceChoice[]
requiredCapability?: string
+ providers: ReadonlySet
+ detectedAgents: readonly string[] | null
+ onProvidersChange(next: Set): void
+ busy?: boolean
}): React.JSX.Element {
const [wslDistros, setWslDistros] = useState([])
const fieldId = useId()
@@ -227,6 +233,14 @@ export function SkillInstallTargetFields(props: {
) : null}
) : null}
+
+
>
)
}
diff --git a/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx b/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx
deleted file mode 100644
index bc660e7ebc7..00000000000
--- a/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-import { AlertTriangle, Loader2, MonitorUp, RotateCcw, Trash2 } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue
-} from '@/components/ui/select'
-import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract'
-import type { SkillInstallResult } from '../../../../shared/skill-install-contract'
-import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
-import { SkillCloudManagementActions } from './SkillCloudManagementActions'
-import type { SkillManagedInstallGroup } from './skill-managed-install-groups'
-import type { skillInstallManagementCopy } from './skill-install-management-copy'
-
-type Copy = ReturnType
-
-export function SkillManagedInstallDetails({
- selected,
- details,
- versionId,
- busy,
- confirmRemove,
- result,
- bundleResult,
- installActive,
- destructiveConflict,
- copy,
- onVersionChange,
- onInstall,
- onCancelInstall,
- onInstallAnotherMachine,
- onRemove,
- onCloudChanged,
- onPackageDeleted
-}: {
- selected: SkillManagedInstallGroup
- details: SkillCloudPackageDetails
- versionId: string
- busy: boolean
- confirmRemove: boolean
- result: SkillInstallResult | null
- bundleResult: SkillBundleInstallResult | null
- installActive: boolean
- destructiveConflict: boolean
- copy: Copy
- onVersionChange: (versionId: string) => void
- onInstall: (discardLocal?: boolean) => void
- onCancelInstall: () => void
- onInstallAnotherMachine: (shareId: string) => void
- onRemove: (discardLocal?: boolean) => void
- onCloudChanged: () => Promise
- onPackageDeleted: () => void
-}): React.JSX.Element {
- const selectedVersion = details.versions.find((version) => version.versionId === versionId)
- const bundleManifest =
- selectedVersion?.manifest && 'skills' in selectedVersion.manifest
- ? selectedVersion.manifest
- : null
- const activeShare = details.management?.shares[0]
- return (
-
-
-
- {bundleManifest?.bundleName ?? selected.installs[0]?.name}
-
-
- {copy.installedVersion} {selected.versionId}
-
- {selected.bundleDigest ? (
-
- {selected.installs.map((install) => (
-
- {install.name}
- {install.state}
-
- ))}
-
- ) : null}
-
-
-
-
-
-
- {details.versions.map((version) => (
-
- {version.versionId} · {new Date(version.createdAt).toLocaleDateString()}
-
- ))}
-
-
- {destructiveConflict ? (
-
-
- {copy.modified}
-
-
{copy.preserveModified}
-
onInstall(true)}>
- {copy.discardAndInstall}
-
-
onRemove(true)}>
- {copy.discardAndRemove}
-
-
- ) : null}
-
- onInstall()}
- >
- {busy ? : }
- {result?.status === 'partial' || bundleResult?.status === 'partial'
- ? copy.retryCoverage
- : selected.bundleDigest
- ? copy.installSkills(selected.installs.length)
- : copy.installVersion}
-
- {installActive ? (
-
- {copy.cancelInstall}
-
- ) : null}
- {activeShare ? (
- onInstallAnotherMachine(activeShare.id)}
- >
- {copy.installAnotherMachine}
-
- ) : null}
- onRemove()}
- >
- {' '}
- {confirmRemove
- ? selected.bundleDigest
- ? copy.confirmRemoveSkills(selected.installs.length)
- : copy.confirmRemove
- : selected.bundleDigest
- ? copy.removeSkills(selected.installs.length)
- : copy.remove}
-
-
-
- {bundleResult ? (
-
- {copy.bundleResult(
- bundleResult.skills.filter((skill) => skill.status === 'installed').length,
- bundleResult.skills.filter((skill) => skill.status === 'updated').length,
- bundleResult.skills.filter((skill) => skill.status === 'kept-local').length
- )}
-
- ) : null}
-
- )
-}
diff --git a/src/renderer/src/components/skills/SkillManagedInstallList.tsx b/src/renderer/src/components/skills/SkillManagedInstallList.tsx
deleted file mode 100644
index 9276d9a548f..00000000000
--- a/src/renderer/src/components/skills/SkillManagedInstallList.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { translate } from '@/i18n/i18n'
-import { groupInstallState, type SkillManagedInstallGroup } from './skill-managed-install-groups'
-
-export function SkillManagedInstallList({
- groups,
- selectedKey,
- onSelect
-}: {
- groups: SkillManagedInstallGroup[]
- selectedKey: string
- onSelect: (group: SkillManagedInstallGroup) => void
-}): React.JSX.Element {
- return (
-
- {groups.map((group) => {
- const state = groupInstallState(group)
- const isBundle = Boolean(group.bundleDigest)
- return (
- onSelect(group)}
- >
-
-
- {isBundle
- ? translate(
- 'auto.components.skills.SkillManagedInstallList.86c76cb262',
- '{{count}} skill bundle',
- { count: group.installs.length }
- )
- : group.installs[0]?.name}
-
-
- {group.installs[0]?.scope} · {group.versionId}
-
-
-
- {state}
-
-
- )
- })}
-
- )
-}
diff --git a/src/renderer/src/components/skills/SkillManagedInstallRow.tsx b/src/renderer/src/components/skills/SkillManagedInstallRow.tsx
new file mode 100644
index 00000000000..c2330467f55
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillManagedInstallRow.tsx
@@ -0,0 +1,205 @@
+import { ChevronRight, Loader2, MonitorUp, RotateCcw, Trash2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract'
+import type { SkillInstallResult } from '../../../../shared/skill-install-contract'
+import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
+import type { SkillManagedInstallGroup } from './skill-managed-install-groups'
+import {
+ managedInstalledOnLabel,
+ managedInstallScopeLabel,
+ managedInstallStateNote,
+ managedInstallTitle,
+ managedSkillStateNote,
+ managedVersionLabel
+} from './skill-managed-install-labels'
+
+function SkillLines({ group }: { group: SkillManagedInstallGroup }): React.JSX.Element {
+ return (
+
+ {group.installs.map((install) => {
+ const note = managedSkillStateNote(install.state)
+ return (
+
+ {install.name}
+ {note ? {note} : null}
+
+ )
+ })}
+
+ )
+}
+
+export function SkillManagedInstallRow({
+ group,
+ open,
+ details,
+ versionId,
+ busy,
+ confirmRemove,
+ installActive,
+ editedWarning,
+ bundleResult,
+ result,
+ onOpenChange,
+ onVersionChange,
+ onInstall,
+ onCancelInstall,
+ onSendToMachine,
+ onRemove
+}: {
+ group: SkillManagedInstallGroup
+ open: boolean
+ details: SkillCloudPackageDetails | null
+ versionId: string
+ busy: boolean
+ confirmRemove: boolean
+ installActive: boolean
+ editedWarning: boolean
+ bundleResult: SkillBundleInstallResult | null
+ result: SkillInstallResult | null
+ onOpenChange: (open: boolean) => void
+ onVersionChange: (versionId: string) => void
+ onInstall: (discardLocal?: boolean) => void
+ onCancelInstall: () => void
+ onSendToMachine: (shareId: string) => void
+ onRemove: (discardLocal?: boolean) => void
+}): React.JSX.Element {
+ const stateNote = managedInstallStateNote(group)
+ const installedOn = managedInstalledOnLabel(group)
+ const activeShare = details?.management?.shares[0]
+ const switching = Boolean(versionId) && versionId !== group.versionId
+ const retrying = result?.status === 'partial' || bundleResult?.status === 'partial'
+
+ return (
+
+
+
+
+ {managedInstallTitle(group)}
+
+ {group.installs.map((install) => install.name).join(', ')}
+ {' · '}
+ {managedInstallScopeLabel(group)}
+
+
+ {stateNote ? (
+ {stateNote}
+ ) : null}
+ {installedOn ? (
+ {installedOn}
+ ) : null}
+
+
+
+ {group.installs.length > 1 ? : null}
+ {details ? (
+
+
+ {translate('auto.components.skills.managedInstall.versionLabel', 'Version')}
+
+
+
+
+
+
+ {details.versions.map((version) => (
+
+ {managedVersionLabel(version, group.versionId)}
+
+ ))}
+
+
+
+ ) : null}
+ {editedWarning ? (
+
+ {translate(
+ 'auto.components.skills.managedInstall.editedWarning',
+ 'Your edits are kept unless you choose to discard them.'
+ )}
+
+ ) : null}
+
+ {details ? (
+ onInstall()}>
+ {busy ? (
+
+ ) : (
+
+ )}
+ {retrying
+ ? translate(
+ 'auto.components.skills.managedInstall.finishInstall',
+ 'Finish installing'
+ )
+ : switching
+ ? translate(
+ 'auto.components.skills.managedInstall.useVersion',
+ 'Use this version'
+ )
+ : translate('auto.components.skills.managedInstall.reinstall', 'Reinstall')}
+
+ ) : null}
+ {installActive ? (
+
+ {translate('auto.components.skills.managedInstall.cancel', 'Cancel')}
+
+ ) : null}
+ {activeShare ? (
+ onSendToMachine(activeShare.id)}
+ >
+
+ {translate(
+ 'auto.components.skills.managedInstall.installElsewhere',
+ 'Install elsewhere…'
+ )}
+
+ ) : null}
+ onRemove()}
+ >
+
+ {confirmRemove
+ ? translate('auto.components.skills.managedInstall.confirmRemove', 'Confirm remove')
+ : translate('auto.components.skills.managedInstall.remove', 'Remove')}
+
+ {editedWarning ? (
+ onRemove(true)}>
+ {translate(
+ 'auto.components.skills.managedInstall.discardEditsRemove',
+ 'Discard my edits and remove'
+ )}
+
+ ) : null}
+ {editedWarning ? (
+ onInstall(true)}>
+ {translate(
+ 'auto.components.skills.managedInstall.discardEdits',
+ 'Discard my edits and reinstall'
+ )}
+
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillPackageChecklist.tsx b/src/renderer/src/components/skills/SkillPackageChecklist.tsx
new file mode 100644
index 00000000000..e3ee36f96ae
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillPackageChecklist.tsx
@@ -0,0 +1,136 @@
+import { AlertTriangle, ChevronRight } from 'lucide-react'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { byteLabel } from './skill-share-preview-summary'
+import {
+ checklistItemSummary,
+ isScriptFile,
+ type SkillChecklistFile,
+ type SkillChecklistItem
+} from './skill-package-checklist-items'
+
+function FileRow({ file }: { file: SkillChecklistFile }): React.JSX.Element {
+ const tag = file.executable
+ ? translate('auto.components.skills.share.fileExecutable', 'executable')
+ : isScriptFile(file)
+ ? translate('auto.components.skills.share.fileScript', 'script')
+ : null
+ return (
+
+
+ {file.path}
+
+ {tag ? {tag} : null}
+ {byteLabel(file.size)}
+
+ )
+}
+
+function ChecklistRow({
+ item,
+ selected,
+ note,
+ busy,
+ onSelectedChange
+}: {
+ item: SkillChecklistItem
+ selected: boolean | null
+ note: string | null
+ busy: boolean
+ onSelectedChange?: (selected: boolean) => void
+}): React.JSX.Element {
+ const summary = checklistItemSummary(item.files)
+ const description =
+ item.description ||
+ translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')
+ return (
+
+ {/* Why: the description stays on the row and simply unclamps when the row
+ opens, so nothing is repeated and nothing is hidden behind a click. */}
+
+
+ {selected !== null ? (
+
onSelectedChange?.(checked === true)}
+ />
+ ) : null}
+
+
+ {item.name}
+
+ {description}
+
+
+ {note ? (
+ {note}
+ ) : null}
+
+ {summary.risky ? : null}
+ {summary.label}
+
+
+
+
+
+
+ {item.files.map((file) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+/**
+ * What the link actually contains: one row per skill, each expandable to its
+ * description and file list. Selection is offered only when there is a choice
+ * to make — a single-skill package installs as one unit.
+ */
+export function SkillPackageChecklist({
+ items,
+ selectedIds,
+ notes,
+ busy,
+ onSelectedChange
+}: {
+ items: readonly SkillChecklistItem[]
+ selectedIds: ReadonlySet | null
+ notes?: ReadonlyMap
+ busy: boolean
+ onSelectedChange?: (skillId: string, selected: boolean) => void
+}): React.JSX.Element {
+ return (
+
+ {items.map((item) => (
+ onSelectedChange?.(item.id, selected)}
+ />
+ ))}
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillRow.tsx b/src/renderer/src/components/skills/SkillRow.tsx
new file mode 100644
index 00000000000..e4d9b63cb26
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillRow.tsx
@@ -0,0 +1,275 @@
+import { useId, useRef } from 'react'
+import { ClipboardCopy, FolderOpen, Info, MoreHorizontal, Share2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger
+} from '@/components/ui/context-menu'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import type { DiscoveredSkill, SkillProvider } from '../../../../shared/skills'
+import { sourceKindLabel } from './skill-display-labels'
+
+const providerLabels: Record = {
+ codex: 'Codex',
+ claude: 'Claude',
+ 'agent-skills': 'Agent Skills'
+}
+
+const dateFormatter = new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: 'numeric'
+})
+
+function formatUpdatedAt(value: number | null): string {
+ return value
+ ? dateFormatter.format(new Date(value))
+ : translate('auto.components.skills.SkillRow.updatedUnknown', 'No date')
+}
+
+type SkillRowAction = {
+ key: string
+ label: string
+ icon: React.JSX.Element
+ disabled?: boolean
+ onSelect: () => void
+}
+
+export function SkillRow({
+ skill,
+ selectionMode,
+ selected,
+ selectable,
+ shareable,
+ disabledReason,
+ focusable,
+ onOpenDetail,
+ onSelectionChange,
+ onShare,
+ onFocus,
+ onKeyDown
+}: {
+ skill: DiscoveredSkill
+ selectionMode: boolean
+ selected: boolean
+ selectable: boolean
+ shareable: boolean
+ /** Row-specific explanation only; page-wide causes are stated once above the
+ * list instead of once per row. */
+ disabledReason: string | null
+ focusable: boolean
+ onOpenDetail: () => void
+ onSelectionChange: (selected: boolean, range: boolean) => void
+ onShare: () => void
+ onFocus: () => void
+ onKeyDown: (event: React.KeyboardEvent) => void
+}): React.JSX.Element {
+ const reasonId = useId()
+ // Why: Radix's checkbox callback carries no event, so the modifier has to be
+ // captured from the pointer press that produced it.
+ const rangeRef = useRef(false)
+ const selectionBlocked = selectionMode && !selectable
+ const showReason = selectionBlocked && disabledReason !== null
+
+ const revealSkill = async (): Promise => {
+ const result = await window.api.shell.openInFileManager(skill.skillFilePath)
+ if (!result.ok) {
+ toast.error(
+ translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file')
+ )
+ }
+ }
+
+ const copyPath = async (): Promise => {
+ await window.api.ui.writeClipboardText(skill.skillFilePath)
+ toast.success(translate('auto.components.skills.SkillRow.pathCopied', 'Path copied'))
+ }
+
+ const actions: SkillRowAction[] = [
+ {
+ key: 'details',
+ label: translate('auto.components.skills.SkillRow.viewDetails', 'View details'),
+ icon: ,
+ onSelect: onOpenDetail
+ },
+ {
+ key: 'share',
+ label: translate('auto.components.skills.SkillCard.d25a1b8ae6', 'Share skill'),
+ icon: ,
+ disabled: !shareable,
+ onSelect: onShare
+ },
+ {
+ key: 'reveal',
+ label: translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file'),
+ icon: ,
+ onSelect: () => void revealSkill()
+ },
+ {
+ key: 'copy-path',
+ label: translate('auto.components.skills.SkillRow.copyPath', 'Copy path'),
+ icon: ,
+ onSelect: () => void copyPath()
+ }
+ ]
+
+ const activate = (): void => {
+ if (selectionMode) {
+ if (!selectionBlocked) {
+ onSelectionChange(!selected, rangeRef.current)
+ }
+ return
+ }
+ onOpenDetail()
+ }
+
+ return (
+
+
+ {
+ rangeRef.current = event.shiftKey
+ }}
+ onClick={activate}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ activate()
+ return
+ }
+ onKeyDown(event)
+ }}
+ className={cn(
+ 'group flex w-full cursor-pointer items-start gap-3 border-b border-border/50 px-2 py-2.5 text-left transition-colors last:border-b-0 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
+ selected && 'bg-accent',
+ selectionBlocked && 'opacity-60'
+ )}
+ >
+ {selectionMode ? (
+
e.stopPropagation()}
+ >
+ onSelectionChange(value === true, rangeRef.current)}
+ />
+
+ ) : null}
+ {/* Why: metadata is its own grid column, so the description truncates
+ where that column starts instead of at the window edge. */}
+
+
+
+ {skill.name}
+
+ {!skill.installed ? (
+
+ {translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')}
+
+ ) : null}
+ {showReason ? (
+
+
+
+ {translate('auto.components.skills.SkillRow.notShareable', 'Not shareable')}
+
+
+
+ {disabledReason}
+
+
+ ) : null}
+
+
+ {sourceKindLabel(skill.sourceKind)}
+
+ ·
+
+
+ {skill.providers.map((provider) => providerLabels[provider]).join(', ')}
+
+ ·
+ {formatUpdatedAt(skill.updatedAt)}
+
+
+ {skill.description ??
+ translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')}
+
+ {showReason ? (
+
+ {disabledReason}
+
+ ) : null}
+
+
event.stopPropagation()}
+ >
+
+
+
+
+
+
+
+ {actions.map((action) => (
+
+ {action.icon}
+ {action.label}
+
+ ))}
+
+
+
+
+
+
+ {actions.map((action) => (
+
+ {action.icon}
+ {action.label}
+
+ ))}
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillShareDialog.test.tsx b/src/renderer/src/components/skills/SkillShareDialog.test.tsx
index b4fde4fb49b..9e3a6b52dea 100644
--- a/src/renderer/src/components/skills/SkillShareDialog.test.tsx
+++ b/src/renderer/src/components/skills/SkillShareDialog.test.tsx
@@ -50,6 +50,14 @@ const secondSkill: DiscoveredSkill = {
skillFilePath: '/home/skills/second-skill/SKILL.md'
}
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise
+ })
+ return { promise, resolve }
+}
+
function managedInstall(destinationIdentity: string): ManagedSkillInstall {
return {
name: 'private-skill',
@@ -67,12 +75,19 @@ function managedInstall(destinationIdentity: string): ManagedSkillInstall {
function setup(
installs: ManagedSkillInstall[],
organization = false,
- selectedSkills: DiscoveredSkill[] = [skill]
+ selectedSkills: DiscoveredSkill[] = [skill],
+ previewOverrides: Partial = {},
+ preparation?: Promise,
+ authStatus: Promise = Promise.resolve({
+ cloud: { email: 'owner@example.com', ...(organization ? { activeOrgId: 'org_1' } : {}) }
+ })
) {
let progressListener: ((progress: SkillShareProgress) => void) | null = null
const skills = {
listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: installs }),
- prepareShare: vi.fn().mockResolvedValue(preview),
+ prepareShare: vi
+ .fn()
+ .mockReturnValue(preparation ?? Promise.resolve({ ...preview, ...previewOverrides })),
publishShare: vi.fn(),
cancelShare: vi.fn().mockResolvedValue(undefined),
releaseShare: vi.fn().mockResolvedValue(undefined),
@@ -86,12 +101,7 @@ function setup(
value: {
skills,
orcaProfiles: {
- authStatus: vi.fn().mockResolvedValue({
- cloud: {
- email: 'owner@example.com',
- ...(organization ? { activeOrgId: 'org_1', activeOrgName: 'Orca' } : {})
- }
- }),
+ authStatus: vi.fn().mockReturnValue(authStatus),
orgMembersList: vi.fn().mockResolvedValue({
status: 'ok',
roster: { members: [] }
@@ -99,10 +109,13 @@ function setup(
}
}
})
- render( undefined} />)
+ const view = render(
+ undefined} />
+ )
return {
skills,
- emitProgress: (progress: SkillShareProgress) => progressListener?.(progress)
+ emitProgress: (progress: SkillShareProgress) => progressListener?.(progress),
+ unmount: view.unmount
}
}
@@ -113,9 +126,60 @@ afterEach(() => {
})
describe('SkillShareDialog', () => {
+ it('releases a preparation that resolves after the dialog unmounts', async () => {
+ const preparation = deferred()
+ const { skills, unmount } = setup([], false, [skill], {}, preparation.promise)
+ await waitFor(() => expect(skills.prepareShare).toHaveBeenCalledOnce())
+
+ unmount()
+ preparation.resolve(preview)
+
+ await waitFor(() => expect(skills.releaseShare).toHaveBeenCalledWith(preview.preparationId))
+ })
+
+ it('releases a prepared archive when account inspection fails', async () => {
+ const { skills } = setup(
+ [],
+ false,
+ [skill],
+ {},
+ undefined,
+ Promise.reject(new Error('auth unavailable'))
+ )
+
+ await waitFor(() => expect(skills.releaseShare).toHaveBeenCalledWith(preview.preparationId))
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ 'Could not prepare this skill for sharing.'
+ )
+ })
+
+ it('releases a prepared archive when the dialog unmounts', async () => {
+ const { skills, unmount } = setup([])
+ await screen.findByRole('heading', { name: 'Share skill' })
+
+ unmount()
+
+ await waitFor(() => expect(skills.releaseShare).toHaveBeenCalledWith(preview.preparationId))
+ })
+
+ it('closes even when preparation cleanup fails', async () => {
+ const onOpenChange = vi.fn()
+ const { skills } = setup([])
+ skills.releaseShare.mockRejectedValue(new Error('busy'))
+ await screen.findByRole('heading', { name: 'Share skill' })
+
+ cleanup()
+ render( )
+ await screen.findByRole('heading', { name: 'Share skill' })
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
it('accepts multiline release notes up to the published limit', async () => {
setup([], false, [skill, secondSkill])
await screen.findByRole('heading', { name: 'Share skill bundle' })
+ fireEvent.click(screen.getByRole('button', { name: 'Add release notes (optional)' }))
const notes = screen.getByRole('textbox', { name: 'Release notes' }) as HTMLTextAreaElement
const value = `Release summary\n${'x'.repeat(9_984)}`
@@ -124,6 +188,40 @@ describe('SkillShareDialog', () => {
expect(notes.value).toBe(value)
})
+ // Why: "what changed in this version?" has no answer on a first publish, so
+ // the field only opens itself when there is a previous version to compare to.
+ it('opens release notes only when publishing a new version', async () => {
+ const { skills } = setup([managedInstall('global:local')])
+ await screen.findByRole('heading', { name: 'Publish new skill version' })
+ expect(screen.getByRole('textbox', { name: 'Release notes' })).toBeTruthy()
+ expect(skills.prepareShare).toHaveBeenCalled()
+
+ cleanup()
+ setup([])
+ await screen.findByRole('heading', { name: 'Share skill' })
+ expect(screen.queryByRole('textbox', { name: 'Release notes' })).toBeNull()
+ })
+
+ it('names the files that can run instead of counting zeros', async () => {
+ setup([], false, [skill], {
+ scriptPaths: ['scripts/setup.sh'],
+ executablePaths: ['bin/tool']
+ })
+ await screen.findByRole('heading', { name: 'Share skill' })
+ expect(screen.getByText('1 script, 1 executable')).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Review files that can run' }))
+ expect(screen.getByText('scripts/setup.sh')).toBeTruthy()
+ expect(screen.getByText('bin/tool')).toBeTruthy()
+ })
+
+ it('states that nothing in the package can run when no file can', async () => {
+ setup([])
+ await screen.findByRole('heading', { name: 'Share skill' })
+ expect(screen.getByText('No scripts or executables')).toBeTruthy()
+ expect(screen.queryByRole('button', { name: 'Review files that can run' })).toBeNull()
+ })
+
it('publishes a new immutable version for one exact managed-install match', async () => {
const { skills } = setup([managedInstall('global:local')])
@@ -166,7 +264,7 @@ describe('SkillShareDialog', () => {
expect(screen.getByRole('button', { name: 'Publish new version' })).toBeTruthy()
expect(skills.prepareShare).toHaveBeenCalledWith({
skillIds: [skill.id, secondSkill.id],
- bundleName: 'shared-skills',
+ bundleName: 'private-skill-and-1-more',
packageId: 'pkg_1'
})
})
diff --git a/src/renderer/src/components/skills/SkillShareDialog.tsx b/src/renderer/src/components/skills/SkillShareDialog.tsx
index 1c94ed314e2..145f9066095 100644
--- a/src/renderer/src/components/skills/SkillShareDialog.tsx
+++ b/src/renderer/src/components/skills/SkillShareDialog.tsx
@@ -13,9 +13,13 @@ import type { DiscoveredSkill } from '../../../../shared/skills'
import {
SkillShareDialogHeader,
SkillSharePreparationReview,
- SkillSharePublishedLink
+ SkillSharePublishProgress,
+ SkillSharePublishedLink,
+ skillShareDialogHasDescription
} from './SkillShareReviewContent'
+import { useAppStore } from '@/store'
import { matchingManagedSkillShareInstall } from './skill-share-package-selection'
+import { derivedBundleName } from './skill-bundle-name'
type SkillShareDialogProps = {
skills?: DiscoveredSkill[]
@@ -44,7 +48,9 @@ export function SkillShareDialog({
}: SkillShareDialogProps): React.JSX.Element {
const selectedSkills = useMemo(() => skills ?? (skill ? [skill] : []), [skill, skills])
const [preview, setPreview] = useState(null)
- const [author, setAuthor] = useState('')
+ // Why: the dialog never shows who is publishing — it only needs to know that a
+ // Cloud account exists, since publishing fails without one.
+ const [hasCloudAccount, setHasCloudAccount] = useState(false)
const [releaseNotes, setReleaseNotes] = useState('')
const [progress, setProgress] = useState(null)
const [shareUrl, setShareUrl] = useState(null)
@@ -55,12 +61,17 @@ export function SkillShareDialog({
const [publishingNewVersion, setPublishingNewVersion] = useState(false)
const generation = useRef(0)
const cancellationRequested = useRef(false)
+ const publishButtonRef = useRef(null)
+ const openSettingsPage = useAppStore((state) => state.openSettingsPage)
+ const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
useEffect(() => {
if (!open || selectedSkills.length === 0) {
return
}
const current = ++generation.current
+ let disposed = false
+ let retainedPreparationId: string | null = null
setPreview(null)
setShareUrl(null)
setProgress(null)
@@ -77,27 +88,34 @@ export function SkillShareDialog({
} catch (cause) {
console.warn('[skills] managed install lookup failed during share:', cause)
}
- const [nextPreview, auth] = await Promise.all([
- window.api.skills.prepareShare({
- skillIds: selectedSkills.map((skill) => skill.id),
- bundleName: selectedSkills.length === 1 ? selectedSkills[0].name : 'shared-skills',
- ...(managedInstall ? { packageId: managedInstall.packageId } : {})
- }),
- window.api.orcaProfiles.authStatus()
- ])
+ const nextPreview = await window.api.skills.prepareShare({
+ skillIds: selectedSkills.map((skill) => skill.id),
+ bundleName: derivedBundleName(selectedSkills),
+ ...(managedInstall ? { packageId: managedInstall.packageId } : {})
+ })
+ retainedPreparationId = nextPreview.preparationId
+ const auth = await window.api.orcaProfiles.authStatus()
return { nextPreview, auth, managedInstall }
})()
.then(async ({ nextPreview, auth, managedInstall }) => {
- if (generation.current !== current) {
+ if (disposed || generation.current !== current) {
await window.api.skills.releaseShare(nextPreview.preparationId)
return
}
+ retainedPreparationId = nextPreview.preparationId
setPreview(nextPreview)
setPublishingNewVersion(managedInstall !== null)
const cloud = auth.cloud
- setAuthor(cloud?.displayName || cloud?.email || '')
+ setHasCloudAccount(Boolean(cloud?.displayName || cloud?.email))
})
- .catch((cause) => {
+ .catch(async (cause) => {
+ const preparationId = retainedPreparationId
+ retainedPreparationId = null
+ if (preparationId) {
+ await window.api.skills.releaseShare(preparationId).catch((releaseCause) => {
+ console.warn('[skills] failed preparation cleanup:', releaseCause)
+ })
+ }
console.warn('[skills] share preparation failed:', cause)
if (generation.current === current) {
setError(
@@ -109,10 +127,23 @@ export function SkillShareDialog({
}
})
.finally(() => {
- if (generation.current === current) {
+ if (!disposed && generation.current === current) {
setPreparing(false)
}
})
+ return () => {
+ disposed = true
+ if (generation.current === current) {
+ generation.current += 1
+ }
+ if (retainedPreparationId) {
+ const preparationId = retainedPreparationId
+ retainedPreparationId = null
+ void window.api.skills.releaseShare(preparationId).catch((cause) => {
+ console.warn('[skills] share preparation cleanup failed:', cause)
+ })
+ }
+ }
}, [open, selectedSkills])
useEffect(() => {
@@ -126,6 +157,14 @@ export function SkillShareDialog({
})
}, [preview])
+ // Why: nothing has to be typed to publish, so the primary action takes focus
+ // as soon as there is something to publish — Enter then completes the flow.
+ useEffect(() => {
+ if (preview && !shareUrl) {
+ publishButtonRef.current?.focus()
+ }
+ }, [preview, shareUrl])
+
const progressPercent = useMemo(() => {
if (!progress || progress.totalBytes === 0) {
return 0
@@ -135,10 +174,13 @@ export function SkillShareDialog({
const close = async (): Promise => {
generation.current += 1
- if (preview && !shareUrl) {
- await window.api.skills.releaseShare(preview.preparationId)
- }
onOpenChange(false)
+ if (!preview || shareUrl) {
+ return
+ }
+ await window.api.skills.releaseShare(preview.preparationId).catch((cause) => {
+ console.warn('[skills] share preparation cleanup failed:', cause)
+ })
}
const publish = async (): Promise => {
@@ -203,79 +245,111 @@ export function SkillShareDialog({
return (
!next && !publishing && void close()}>
-
+ {/* Why: the review scrolls on its own so progress, errors, and the primary
+ action stay visible on a short window instead of below the fold. */}
+
- {preparing ? (
-
-
- {translate('auto.components.skills.SkillShareDialog.preparing', 'Preparing preview…')}
-
- ) : preview && !shareUrl ? (
-
- ) : shareUrl ? (
- void copyLink()} />
- ) : null}
-
- {error ? (
-
- {error}
-
- ) : null}
-
- void close()} disabled={publishing}>
- {shareUrl
- ? translate('auto.components.skills.SkillShareDialog.3af85f6add', 'Done')
- : translate('auto.components.skills.SkillShareDialog.30985d4fc0', 'Cancel')}
-
- {!shareUrl ? (
- publishing ? (
- void cancelPublish()}
- >
- {cancelling
- ? translate('auto.components.skills.SkillShareDialog.e9d652ae3d', 'Cancelling…')
- : translate(
- 'auto.components.skills.SkillShareDialog.3a51d0f34f',
- 'Cancel upload'
- )}
-
- ) : (
- void publish()}>
-
- {publishingNewVersion
- ? translate(
- 'auto.components.skills.SkillShareDialog.7aa4ba0dba',
- 'Publish new version'
- )
- : selectedSkills.length > 1
- ? translate(
- 'auto.components.skills.SkillShareDialog.publishBundle',
- 'Publish bundle'
- )
- : translate(
- 'auto.components.skills.SkillShareDialog.0f07fa2a79',
- 'Publish skill'
- )}
-
- )
+
+ {preparing ? (
+
+
+ {translate('auto.components.skills.SkillShareDialog.preparing', 'Preparing preview…')}
+
+ ) : preview && !shareUrl ? (
+
void publish()}
+ />
+ ) : shareUrl && preview ? (
+ void copyLink()}
+ onManageLinks={() => {
+ openSettingsTarget({ pane: 'share-skills', repoId: null })
+ openSettingsPage()
+ void close()
+ }}
+ />
) : null}
-
+
+
+
+ {publishing ? (
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ void close()}
+ disabled={publishing}
+ >
+ {shareUrl
+ ? translate('auto.components.skills.SkillShareDialog.3af85f6add', 'Done')
+ : translate('auto.components.skills.SkillShareDialog.30985d4fc0', 'Cancel')}
+
+ {!shareUrl ? (
+ publishing ? (
+ void cancelPublish()}
+ >
+ {cancelling
+ ? translate('auto.components.skills.SkillShareDialog.e9d652ae3d', 'Cancelling…')
+ : translate(
+ 'auto.components.skills.SkillShareDialog.3a51d0f34f',
+ 'Cancel upload'
+ )}
+
+ ) : (
+ void publish()}
+ >
+
+ {publishingNewVersion
+ ? translate(
+ 'auto.components.skills.SkillShareDialog.7aa4ba0dba',
+ 'Publish new version'
+ )
+ : selectedSkills.length > 1
+ ? translate(
+ 'auto.components.skills.SkillShareDialog.publishBundle',
+ 'Publish bundle'
+ )
+ : translate(
+ 'auto.components.skills.SkillShareDialog.0f07fa2a79',
+ 'Publish skill'
+ )}
+
+ )
+ ) : null}
+
+
)
diff --git a/src/renderer/src/components/skills/SkillSharePackageSummary.tsx b/src/renderer/src/components/skills/SkillSharePackageSummary.tsx
new file mode 100644
index 00000000000..5f187cf93be
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillSharePackageSummary.tsx
@@ -0,0 +1,108 @@
+import { AlertTriangle, FileCode2 } from 'lucide-react'
+import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import type { SkillSharePreview } from '../../../../shared/skill-sharing-contract'
+import { fileCountLabel, skillCountLabel } from './skill-display-labels'
+import { byteLabel, sensitiveShareFiles, summarizeShareRisk } from './skill-share-preview-summary'
+import { SkillDescriptionDisclosure } from './SkillDescriptionDisclosure'
+import { SkillDisclosureTrigger } from './SkillDisclosureTrigger'
+
+function SensitiveFileList({ preview }: { preview: SkillSharePreview }): React.JSX.Element {
+ return (
+
+ {sensitiveShareFiles(preview).map((file) => (
+
+
+ {file.path}
+
+
+ {file.executable
+ ? translate('auto.components.skills.share.fileExecutable', 'executable')
+ : translate('auto.components.skills.share.fileScript', 'script')}
+
+
+ ))}
+
+ )
+}
+
+function BundleSkillList({ preview }: { preview: SkillSharePreview }): React.JSX.Element {
+ return (
+
+ {(preview.skills ?? []).map((skill) => (
+
+
+
{skill.name}
+
{skill.description}
+
+
+ {fileCountLabel(skill.fileCount)} · {byteLabel(skill.totalBytes)}
+
+
+ ))}
+
+ )
+}
+
+export function SkillSharePackageSummary({
+ preview
+}: {
+ preview: SkillSharePreview
+}): React.JSX.Element {
+ const skillCount = preview.skillCount ?? preview.skills?.length ?? 1
+ const bundle = skillCount > 1
+ const risk = summarizeShareRisk(preview)
+ return (
+
+
+
+
+
+
+ {/* Why: a bundle's package name is synthesized ("shared-skills"), so the
+ count is the only honest heading; the list below names the members. */}
+
+ {bundle ? skillCountLabel(skillCount) : preview.name}
+
+ {bundle ? null :
}
+
+ {fileCountLabel(preview.fileCount)}
+ ·
+ {byteLabel(preview.totalBytes)}
+ ·
+
+ {risk.risky ? : null}
+ {risk.label}
+
+
+
+
+
+ {risk.risky ? (
+
+
+
+
+
+
+ ) : null}
+
+ {bundle && (preview.skills?.length ?? 0) > 0 ? (
+
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillShareReleaseNotesField.tsx b/src/renderer/src/components/skills/SkillShareReleaseNotesField.tsx
new file mode 100644
index 00000000000..7b2a540e8c8
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillShareReleaseNotesField.tsx
@@ -0,0 +1,68 @@
+import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'
+import { Label } from '@/components/ui/label'
+import { translate } from '@/i18n/i18n'
+import { SkillDisclosureTrigger } from './SkillDisclosureTrigger'
+
+/**
+ * Collapsed on a first publish, where "what changed" has no answer yet, and open
+ * for a new version, where the notes are the reason to read the dialog.
+ */
+export function SkillShareReleaseNotesField({
+ value,
+ newVersion,
+ onChange,
+ onSubmit
+}: {
+ value: string
+ newVersion: boolean
+ onChange: (value: string) => void
+ onSubmit: () => void
+}): React.JSX.Element {
+ const isMac = navigator.userAgent.includes('Mac')
+ return (
+
+
+
+
+ {translate('auto.components.skills.SkillShareReviewContent.f0c0411549', 'Release notes')}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillShareReviewContent.tsx b/src/renderer/src/components/skills/SkillShareReviewContent.tsx
index 87e745ca023..66276a05731 100644
--- a/src/renderer/src/components/skills/SkillShareReviewContent.tsx
+++ b/src/renderer/src/components/skills/SkillShareReviewContent.tsx
@@ -1,14 +1,24 @@
-import { Check, Clipboard, FileCode2 } from 'lucide-react'
-import { Badge } from '@/components/ui/badge'
+import { Check, Clipboard } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { translate } from '@/i18n/i18n'
import type {
SkillSharePreview,
SkillShareProgress
} from '../../../../shared/skill-sharing-contract'
+import { SkillSharePackageSummary } from './SkillSharePackageSummary'
+import { SkillShareReleaseNotesField } from './SkillShareReleaseNotesField'
+import { shortDigest } from './skill-package-digest'
+
+/** Radix warns about a missing description unless the caller opts out, so the
+ * dialog and its header have to agree on when one exists. */
+export function skillShareDialogHasDescription(
+ published: boolean,
+ publishingNewVersion: boolean
+): boolean {
+ return !published && publishingNewVersion
+}
export function SkillShareDialogHeader({
published,
@@ -47,34 +57,68 @@ export function SkillShareDialogHeader({
)
: translate('auto.components.skills.SkillShareDialog.title', 'Share skill')}
-
- {published
- ? translate(
- 'auto.components.skills.SkillShareDialog.readyDescriptionV2',
- 'Anyone with this unlisted link can inspect and install the skills.'
- )
- : publishingNewVersion
- ? translate(
- 'auto.components.skills.SkillShareDialog.newVersionDescription',
- 'Review the exact files, then publish an immutable version to the existing Cloud package.'
- )
- : translate(
- 'auto.components.skills.SkillShareDialog.descriptionV2',
- 'Review the exact files, then publish an immutable version behind an unlisted link.'
- )}
-
+ {/* Why: the title and the access line already say what this does, so the
+ only description left is the one case with a non-obvious outcome —
+ publishing again adds a version instead of replacing the old one. */}
+ {skillShareDialogHasDescription(published, publishingNewVersion) ? (
+
+ {translate(
+ 'auto.components.skills.share.newVersionDescriptionPlain',
+ 'Adds a new version to the existing link.'
+ )}
+
+ ) : null}
)
}
-function byteLabel(bytes: number): string {
- if (bytes < 1024) {
- return `${bytes} B`
- }
- if (bytes < 1024 * 1024) {
- return `${(bytes / 1024).toFixed(1)} KB`
- }
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+function SkillShareAccessSummary({
+ hasCloudAccount
+}: {
+ hasCloudAccount: boolean
+}): React.JSX.Element {
+ return (
+
+ {hasCloudAccount
+ ? translate(
+ 'auto.components.skills.share.accessSummaryPlain',
+ 'Unlisted link — anyone with it can install this.'
+ )
+ : translate(
+ 'auto.components.skills.SkillShareReviewContent.c15d90c10b',
+ 'A connected Orca Cloud account is required.'
+ )}
+
+ )
+}
+
+export function SkillSharePreparationReview({
+ preview,
+ hasCloudAccount,
+ releaseNotes,
+ publishingNewVersion,
+ onReleaseNotesChange,
+ onSubmit
+}: {
+ preview: SkillSharePreview
+ hasCloudAccount: boolean
+ releaseNotes: string
+ publishingNewVersion: boolean
+ onReleaseNotesChange: (notes: string) => void
+ onSubmit: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+
+
+ )
}
function publishingPhaseLabel(progress: SkillShareProgress | null): string {
@@ -93,187 +137,66 @@ function publishingPhaseLabel(progress: SkillShareProgress | null): string {
return translate('auto.components.skills.SkillShareReviewContent.0142581727', 'Uploading…')
}
-export function SkillSharePreparationReview({
- preview,
- author,
- releaseNotes,
- onReleaseNotesChange,
- publishing,
+/** Rendered outside the scrolling review so upload feedback stays on screen next
+ * to the button that started it. */
+export function SkillSharePublishProgress({
progress,
progressPercent
}: {
- preview: SkillSharePreview
- author: string
- releaseNotes: string
- onReleaseNotesChange: (notes: string) => void
- publishing: boolean
progress: SkillShareProgress | null
progressPercent: number
}): React.JSX.Element {
- const skillPreviews = preview.skills ?? []
- const phaseLabel = publishingPhaseLabel(progress)
+ const settled = progress?.phase === 'finalizing' || progress?.phase === 'publishing'
+ const value = settled ? 100 : progressPercent
return (
-
-
-
-
-
-
-
-
{preview.name}
-
{preview.description}
-
-
-
-
- {translate(
- 'auto.components.skills.SkillShareReviewContent.01c5a17e01',
- '{{value0}} skills',
- { value0: preview.skillCount ?? preview.skills?.length ?? 1 }
- )}
-
-
- {preview.fileCount}{' '}
- {translate('auto.components.skills.SkillShareReviewContent.3121f44358', 'files')}
-
- {byteLabel(preview.totalBytes)}
-
- {preview.scriptPaths.length}{' '}
- {translate('auto.components.skills.SkillShareReviewContent.8edd32622f', 'scripts')}
-
-
- {preview.executablePaths.length}{' '}
- {translate('auto.components.skills.SkillShareReviewContent.77f636eac3', 'executable')}
-
-
-
- {translate('auto.components.skills.SkillShareReviewContent.b3b1d4b911', 'SHA-256')}{' '}
- {preview.packageDigest}
-
- {skillPreviews.length > 1 ? (
-
-
- {translate(
- 'auto.components.skills.SkillShareReviewContent.01c5a17e02',
- 'Review included skills'
- )}
-
-
- {skillPreviews.map((skill) => (
-
-
-
{skill.name}
-
{skill.description}
-
-
- {translate(
- 'auto.components.skills.SkillShareReviewContent.01c5a17e03',
- '{{value0}} files · {{value1}}',
- { value0: skill.fileCount, value1: byteLabel(skill.totalBytes) }
- )}
-
-
- ))}
-
-
- ) : null}
-
-
-
-
-
- {translate(
- 'auto.components.skills.SkillShareReviewContent.unlistedLinkTitle',
- 'Unlisted link'
- )}
-
-
- {author
- ? translate(
- 'auto.components.skills.SkillShareReviewContent.unlistedPublishingAs',
- 'Publishing as {{value0}}. Anyone with the link can inspect and install these skills.',
- { value0: author }
- )
- : translate(
- 'auto.components.skills.SkillShareReviewContent.c15d90c10b',
- 'A connected Orca Cloud account is required.'
- )}
-
-
-
- {translate(
- 'auto.components.skills.SkillShareReviewContent.unlistedLinkDetails',
- 'The link is not searchable or listed publicly. Revoke it to block future access; installed copies remain.'
- )}
-
-
-
-
-
- {translate('auto.components.skills.SkillShareReviewContent.f0c0411549', 'Release notes')}
-
-
-
- {publishing ? (
-
-
- {phaseLabel}
-
- {progress?.phase === 'finalizing' || progress?.phase === 'publishing'
- ? '100%'
- : `${progressPercent}%`}
-
-
-
-
- ) : null}
-
+
+
+ {publishingPhaseLabel(progress)}
+ {`${value}%`}
+
+
+
)
}
export function SkillSharePublishedLink({
shareUrl,
- onCopy
+ packageDigest,
+ onCopy,
+ onManageLinks
}: {
shareUrl: string
+ packageDigest: string
onCopy: () => void
+ onManageLinks: () => void
}): React.JSX.Element {
return (
-
+
{shareUrl}
- {' '}
+
{translate('auto.components.skills.SkillShareReviewContent.6d6233a3a4', 'Copy link')}
-
- {translate(
- 'auto.components.skills.SkillShareReviewContent.e3caf6baeb',
- 'Revoking this link blocks future access. It does not remove copies already installed on recipients’ machines.'
- )}
+
+ {translate('auto.components.skills.SkillShareReviewContent.b3b1d4b911', 'SHA-256')}{' '}
+ {shortDigest(packageDigest)}
+
+ {translate(
+ 'auto.components.skills.share.manageLinks',
+ 'Manage or revoke this link in Settings'
+ )}
+
)
}
diff --git a/src/renderer/src/components/skills/SkillShareSelectableCard.tsx b/src/renderer/src/components/skills/SkillShareSelectableCard.tsx
deleted file mode 100644
index 31acdd05a96..00000000000
--- a/src/renderer/src/components/skills/SkillShareSelectableCard.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { DiscoveredSkill } from '../../../../shared/skills'
-import { SkillCard } from './SkillCard'
-import { isSkillShareEligible, skillShareEligibilityReason } from './SkillShareSelectionControls'
-
-export function SkillShareSelectableCard({
- skill,
- local,
- duplicateNameSelected,
- selected,
- selectionMode,
- onSelectedChange,
- onShare
-}: {
- skill: DiscoveredSkill
- local: boolean
- duplicateNameSelected: boolean
- selected: boolean
- selectionMode: boolean
- onSelectedChange: (selected: boolean) => void
- onShare: () => void
-}): React.JSX.Element {
- const eligible = isSkillShareEligible(skill, local)
- const disabledReason = skillShareEligibilityReason(skill, local, duplicateNameSelected)
- return (
-
- )
-}
diff --git a/src/renderer/src/components/skills/SkillShareSelectionList.tsx b/src/renderer/src/components/skills/SkillShareSelectionList.tsx
deleted file mode 100644
index 0826634c8a0..00000000000
--- a/src/renderer/src/components/skills/SkillShareSelectionList.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { DiscoveredSkill } from '../../../../shared/skills'
-import { SkillShareSelectableCard } from './SkillShareSelectableCard'
-import { selectedShareSkillNameKeys } from './SkillShareSelectionControls'
-
-export function SkillShareSelectionList({
- skills,
- allSkills,
- local,
- selectedIds,
- selectionMode,
- onSelectedChange,
- onShare
-}: {
- skills: readonly DiscoveredSkill[]
- allSkills: readonly DiscoveredSkill[]
- local: boolean
- selectedIds: ReadonlySet
- selectionMode: boolean
- onSelectedChange: (skillId: string, selected: boolean) => void
- onShare: (skill: DiscoveredSkill) => void
-}): React.JSX.Element {
- const selectedNames = selectedShareSkillNameKeys(allSkills, selectedIds)
- return (
-
- {skills.map((skill) => (
- onSelectedChange(skill.id, selected)}
- onShare={() => onShare(skill)}
- />
- ))}
-
- )
-}
diff --git a/src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx b/src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx
new file mode 100644
index 00000000000..791d90b385a
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx
@@ -0,0 +1,83 @@
+// @vitest-environment happy-dom
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import type { SkillCloudOwnedShare } from '../../../../shared/skill-cloud-contract'
+import { SkillSharedLinkRow } from './SkillSharedLinkRow'
+
+const share: SkillCloudOwnedShare = {
+ id: 'shr_1',
+ url: 'https://share.onorca.dev/skills/share/shr_1',
+ packageId: 'pkg_1',
+ name: 'agent-discord-and-1-more',
+ description: 'Team skills',
+ createdAt: '2026-08-12T00:00:00.000Z'
+}
+
+/** Radix opens its menu on pointerdown, which fireEvent.click does not send. */
+async function openDeleteMenu(): Promise {
+ fireEvent.pointerDown(
+ screen.getByRole('button', { name: /More actions/ }),
+ new PointerEvent('pointerdown', { bubbles: true, button: 0 })
+ )
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Delete from the Cloud' }))
+}
+
+function renderRow(onDeleted = vi.fn()): { onDeleted: ReturnType } {
+ render(
+
+
+ undefined}
+ onDeleted={onDeleted}
+ />
+
+
+ )
+ return { onDeleted }
+}
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ Reflect.deleteProperty(window, 'api')
+})
+
+describe('SkillSharedLinkRow', () => {
+ it('deletes the published package only after a confirmation', async () => {
+ const deletePackage = vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills: { deletePackage }, ui: { writeClipboardText: vi.fn() } }
+ })
+ const { onDeleted } = renderRow()
+
+ await openDeleteMenu()
+ expect(deletePackage).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm delete' }))
+
+ expect(deletePackage).toHaveBeenCalledWith('pkg_1')
+ await vi.waitFor(() => expect(onDeleted).toHaveBeenCalledOnce())
+ })
+
+ it('keeps the row when the Cloud rejects the deletion', async () => {
+ const deletePackage = vi
+ .fn()
+ .mockResolvedValue({ status: 'unsupported', message: 'Deletion is unavailable.' })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills: { deletePackage }, ui: { writeClipboardText: vi.fn() } }
+ })
+ const { onDeleted } = renderRow()
+
+ await openDeleteMenu()
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm delete' }))
+
+ await vi.waitFor(() => expect(deletePackage).toHaveBeenCalledOnce())
+ expect(onDeleted).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/skills/SkillSharedLinkRow.tsx b/src/renderer/src/components/skills/SkillSharedLinkRow.tsx
new file mode 100644
index 00000000000..ea3c52ef2a2
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillSharedLinkRow.tsx
@@ -0,0 +1,251 @@
+import { useState } from 'react'
+import { ChevronRight, Clipboard, Link2Off, Loader2, MoreHorizontal, Trash2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { translate } from '@/i18n/i18n'
+import type { SkillCloudOwnedShare } from '../../../../shared/skill-cloud-contract'
+import { skillCountLabel } from './skill-display-labels'
+import { isSkillBundleVersion } from './skill-share-version-summary'
+
+const createdFormatter = new Intl.DateTimeFormat(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+})
+
+function createdLabel(createdAt: string): string {
+ const parsed = new Date(createdAt)
+ return Number.isNaN(parsed.getTime())
+ ? translate('auto.components.skills.SkillRow.updatedUnknown', 'No date')
+ : createdFormatter.format(parsed)
+}
+
+/** The shares endpoint carries no membership, so the skill list is fetched from
+ * the package only when a row is opened — 50 links must not mean 50 requests. */
+function useShareContents(share: SkillCloudOwnedShare): {
+ names: string[] | null
+ failed: boolean
+ load: () => void
+} {
+ const [names, setNames] = useState(null)
+ const [failed, setFailed] = useState(false)
+
+ const load = (): void => {
+ if (names || failed) {
+ return
+ }
+ void (async () => {
+ try {
+ const operation = await window.api.skills.getPackage(share.packageId)
+ if (operation.status !== 'ok') {
+ setFailed(true)
+ return
+ }
+ const version = operation.value.versions.at(0)
+ setNames(
+ version && isSkillBundleVersion(version)
+ ? version.manifest.skills.map((entry) => entry.name)
+ : [share.name]
+ )
+ } catch {
+ setFailed(true)
+ }
+ })()
+ }
+
+ return { names, failed, load }
+}
+
+export function SkillSharedLinkRow({
+ share,
+ busy,
+ onRevoke,
+ onDeleted
+}: {
+ share: SkillCloudOwnedShare
+ busy: boolean
+ onRevoke: () => void
+ onDeleted: () => void
+}): React.JSX.Element {
+ const [confirming, setConfirming] = useState<'revoke' | 'delete' | null>(null)
+ const [deleting, setDeleting] = useState(false)
+ const { names, failed, load } = useShareContents(share)
+
+ const deletePackage = async (): Promise => {
+ setDeleting(true)
+ try {
+ const operation = await window.api.skills.deletePackage(share.packageId)
+ if (operation.status !== 'ok') {
+ toast.error(
+ translate(
+ 'auto.components.skills.SkillSharedLinkRow.deleteFailed',
+ 'Orca could not delete this from the Cloud.'
+ )
+ )
+ return
+ }
+ toast.success(
+ translate('auto.components.skills.SkillSharedLinkRow.deleted', 'Deleted from the Cloud')
+ )
+ onDeleted()
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ const copy = async (): Promise => {
+ await window.api.ui.writeClipboardText(share.url)
+ toast.success(translate('auto.components.settings.shareSkills.linkCopied', 'Share link copied'))
+ }
+
+ return (
+
+ open && load()}>
+
+
+
+ {share.name}
+
+ {share.url}
+
+
+
+ {createdLabel(share.createdAt)}
+
+
+
+
+
+ void copy()}
+ aria-label={translate('auto.components.settings.shareSkills.copyLink', 'Copy link')}
+ >
+
+
+
+
+ {translate('auto.components.settings.shareSkills.copyLink', 'Copy link')}
+
+
+ {confirming ? (
+ (confirming === 'revoke' ? onRevoke() : void deletePackage())}
+ >
+ {busy || deleting ? : null}
+ {confirming === 'revoke'
+ ? translate(
+ 'auto.components.settings.shareSkills.confirmUnshare',
+ 'Confirm unshare'
+ )
+ : translate(
+ 'auto.components.skills.SkillSharedLinkRow.confirmDelete',
+ 'Confirm delete'
+ )}
+
+ ) : (
+ <>
+
+
+ setConfirming('revoke')}
+ aria-label={translate(
+ 'auto.components.settings.shareSkills.unshare',
+ 'Unshare'
+ )}
+ >
+
+
+
+
+ {translate('auto.components.settings.shareSkills.unshare', 'Unshare')}
+
+
+
+
+
+
+
+
+
+ {/* Why: unsharing only closes the link; this removes the
+ published copy itself, so it stays one level down. */}
+ setConfirming('delete')}>
+
+ {translate(
+ 'auto.components.skills.SkillSharedLinkRow.deletePackage',
+ 'Delete from the Cloud'
+ )}
+
+
+
+ >
+ )}
+
+
+ {failed ? (
+
+ {translate(
+ 'auto.components.skills.SkillSharedLinkRow.contentsUnavailable',
+ 'Could not load what this link contains.'
+ )}
+
+ ) : names ? (
+
+
+ {skillCountLabel(names.length)}
+
+
+ {names.map((name) => (
+
+ {name}
+
+ ))}
+
+
+ ) : (
+
+
+ {translate('auto.components.skills.SkillSharedLinkRow.loading', 'Loading contents…')}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillSharedLinksView.tsx b/src/renderer/src/components/skills/SkillSharedLinksView.tsx
new file mode 100644
index 00000000000..4d84930496a
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillSharedLinksView.tsx
@@ -0,0 +1,83 @@
+import { useMemo } from 'react'
+import { translate } from '@/i18n/i18n'
+import { SkillSharedLinkRow } from './SkillSharedLinkRow'
+import type { OwnedSkillShares } from './use-owned-skill-shares'
+
+function matchesQuery(name: string, query: string): boolean {
+ return !query || name.toLocaleLowerCase('en-US').includes(query.toLocaleLowerCase('en-US').trim())
+}
+
+/** The publisher's own links, listed on the page that publishes them so the
+ * record of a share lives beside the thing that made it. */
+export function SkillSharedLinksView({
+ query,
+ shares: { shares, loading, error, busyShareId, revoke, refresh }
+}: {
+ query: string
+ shares: OwnedSkillShares
+}): React.JSX.Element {
+ const visible = useMemo(
+ () => shares.filter((share) => matchesQuery(share.name, query)),
+ [query, shares]
+ )
+
+ if (error) {
+ return (
+
+ {error}
+
+ )
+ }
+
+ if (loading && shares.length === 0) {
+ return (
+
+ {translate('auto.components.skills.SkillSharedLinksView.loading', 'Loading shared links…')}
+
+ )
+ }
+
+ if (shares.length === 0) {
+ return (
+
+ {translate(
+ 'auto.components.settings.shareSkills.noActiveLinks',
+ 'No active links. Publish a skill bundle from Skills to create one.'
+ )}
+
+ )
+ }
+
+ if (visible.length === 0) {
+ return (
+
+ {translate(
+ 'auto.components.skills.SkillSharedLinksView.noMatches',
+ 'No links match this search.'
+ )}
+
+ )
+ }
+
+ return (
+ <>
+
+ {translate(
+ 'auto.components.settings.shareSkills.activeLinksDescription',
+ 'Only people with a link can open it. Unshare a link to block future inspection and installs.'
+ )}
+
+
+ {visible.map((share) => (
+ void revoke(share)}
+ onDeleted={refresh}
+ />
+ ))}
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillsFilterToolbar.tsx b/src/renderer/src/components/skills/SkillsFilterToolbar.tsx
new file mode 100644
index 00000000000..802d0393987
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillsFilterToolbar.tsx
@@ -0,0 +1,213 @@
+import { RefreshCw, Search } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import type { SkillSourceKind } from '../../../../shared/skills'
+import { resultCountLabel, shareLinkCountLabel, sourceKindLabel } from './skill-display-labels'
+import { SKILLS_PAGE_COLUMN } from './skills-page-column'
+import type { SkillsFilterState } from './skills-filter'
+import type { SkillsPageView } from './skills-page-view'
+import type { SkillAgentOption } from './skill-agent-filter'
+
+const SOURCE_KINDS: SkillSourceKind[] = ['home', 'repo', 'bundled', 'plugin']
+
+export function SkillsFilterToolbar({
+ view,
+ filters,
+ agentOptions,
+ sourceCounts,
+ totalCount,
+ resultCount,
+ linkCount,
+ loading,
+ onViewChange,
+ onFiltersChange,
+ onRefresh
+}: {
+ view: SkillsPageView
+ filters: SkillsFilterState
+ agentOptions: readonly SkillAgentOption[]
+ sourceCounts: Record
+ totalCount: number
+ resultCount: number
+ linkCount: number
+ loading: boolean
+ onViewChange: (next: SkillsPageView) => void
+ onFiltersChange: (next: SkillsFilterState) => void
+ onRefresh: () => void
+}): React.JSX.Element {
+ const sharedView = view === 'shared'
+ const filtered =
+ filters.query.trim() !== '' || filters.sourceKind !== 'all' || filters.agent !== 'all'
+ return (
+
+
+
+
+
+ onFiltersChange({ ...filters, query: event.target.value })}
+ placeholder={
+ sharedView
+ ? translate('auto.components.skills.SkillsPage.searchLinks', 'Search links')
+ : translate('auto.components.skills.SkillsPage.a68dee6a32', 'Search skills')
+ }
+ aria-label={
+ sharedView
+ ? translate('auto.components.skills.SkillsPage.searchLinks', 'Search links')
+ : translate('auto.components.skills.SkillsPage.a68dee6a32', 'Search skills')
+ }
+ className="h-8 pl-8 text-sm"
+ />
+
+ {sharedView ? null : (
+
onFiltersChange({ ...filters, agent: value })}
+ >
+
+
+
+
+
+ {translate('auto.components.skills.filter.allAgents', 'All agents')}
+
+ {agentOptions.map((option) => (
+
+ {option.label} {option.count}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+ {translate('auto.components.skills.SkillsPage.cb142070b4', 'Refresh')}
+
+
+
+
+ {/* Why: the view switch shares the chips' pill shape so the row reads
+ as one control strip; the rule marks where filtering starts. */}
+
onViewChange((value || 'skills') as SkillsPageView)}
+ aria-label={translate('auto.components.skills.SkillsPage.viewSwitch', 'Show')}
+ >
+
+ {translate('auto.components.skills.SkillsPage.f43ad6edf3', 'Skills')}
+
+
+ {translate('auto.components.skills.SkillsPage.sharedLinks', 'Shared links')}
+
+
+ {sharedView ? null : (
+ <>
+
+
+ onFiltersChange({
+ ...filters,
+ sourceKind: (value || 'all') as SkillsFilterState['sourceKind']
+ })
+ }
+ aria-label={translate(
+ 'auto.components.skills.SkillsPage.filterSource',
+ 'Filter by source'
+ )}
+ >
+
+ {translate('auto.components.skills.SkillsPage.allSources', 'All')}
+ {totalCount}
+
+ {SOURCE_KINDS.map((sourceKind) => (
+
+ {sourceKindLabel(sourceKind)}
+
+ {sourceCounts[sourceKind]}
+
+
+ ))}
+
+
+
+ {resultCountLabel(resultCount)}
+
+ {filtered ? (
+
+ onFiltersChange({
+ query: '',
+ sourceKind: 'all',
+ agent: 'all'
+ })
+ }
+ >
+ {translate('auto.components.skills.SkillsPage.clearFilters', 'Clear filters')}
+
+ ) : null}
+
+ >
+ )}
+ {sharedView ? (
+
+ {shareLinkCountLabel(linkCount)}
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillsList.tsx b/src/renderer/src/components/skills/SkillsList.tsx
new file mode 100644
index 00000000000..7d89c49f4b4
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillsList.tsx
@@ -0,0 +1,146 @@
+import { useRef, useState } from 'react'
+import { translate } from '@/i18n/i18n'
+import type { DiscoveredSkill } from '../../../../shared/skills'
+import { SkillRow } from './SkillRow'
+import { SkillDetailDialog } from './SkillDetailDialog'
+import {
+ isSkillShareEligible,
+ selectedShareSkillNameKeys,
+ skillShareEligibilityReason
+} from './skill-share-selection'
+
+const OPTION_SELECTOR = '[role="option"]'
+
+function moveOptionFocus(listbox: HTMLElement | null, from: HTMLElement, step: number): void {
+ const options = [...(listbox?.querySelectorAll(OPTION_SELECTOR) ?? [])]
+ options[options.indexOf(from) + step]?.focus()
+}
+
+function focusEdgeOption(listbox: HTMLElement | null, edge: 'first' | 'last'): void {
+ const options = [...(listbox?.querySelectorAll(OPTION_SELECTOR) ?? [])]
+ ;(edge === 'first' ? options.at(0) : options.at(-1))?.focus()
+}
+
+export function SkillsList({
+ skills,
+ allSkills,
+ local,
+ agentByRootPath,
+ selectedIds,
+ selectionMode,
+ onSelectedChange,
+ onSelectResults,
+ onShare
+}: {
+ skills: readonly DiscoveredSkill[]
+ allSkills: readonly DiscoveredSkill[]
+ local: boolean
+ agentByRootPath: ReadonlyMap
+ selectedIds: ReadonlySet
+ selectionMode: boolean
+ onSelectedChange: (skillId: string, selected: boolean) => void
+ onSelectResults: (results: readonly DiscoveredSkill[]) => void
+ onShare: (skill: DiscoveredSkill) => void
+}): React.JSX.Element {
+ const listRef = useRef(null)
+ // Why: an id survives filtering; an index would silently anchor a shift-click
+ // range to whatever row later landed in that slot.
+ const anchorIdRef = useRef(null)
+ const [detailSkill, setDetailSkill] = useState(null)
+ const [focusedId, setFocusedId] = useState(null)
+ const selectedNames = selectedShareSkillNameKeys(allSkills, selectedIds)
+ const focusTargetId = skills.some((skill) => skill.id === focusedId) ? focusedId : skills[0]?.id
+
+ const isMac = navigator.userAgent.includes('Mac')
+
+ const handleSelection = (index: number, selected: boolean, range: boolean): void => {
+ const skill = skills[index]
+ const anchorIndex = skills.findIndex((candidate) => candidate.id === anchorIdRef.current)
+ if (range && selected && anchorIndex >= 0) {
+ const [from, to] = [anchorIndex, index].sort((a, b) => a - b)
+ onSelectResults(skills.slice(from, to + 1))
+ return
+ }
+ anchorIdRef.current = skill.id
+ onSelectedChange(skill.id, selected)
+ }
+
+ const onListKeyDown = (event: React.KeyboardEvent): void => {
+ const option = event.currentTarget
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ moveOptionFocus(listRef.current, option, 1)
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ moveOptionFocus(listRef.current, option, -1)
+ } else if (event.key === 'Home') {
+ event.preventDefault()
+ focusEdgeOption(listRef.current, 'first')
+ } else if (event.key === 'End') {
+ event.preventDefault()
+ focusEdgeOption(listRef.current, 'last')
+ } else if (
+ selectionMode &&
+ event.key.toLowerCase() === 'a' &&
+ (isMac ? event.metaKey : event.ctrlKey)
+ ) {
+ event.preventDefault()
+ onSelectResults(skills)
+ }
+ }
+
+ return (
+ <>
+ {
+ if (!open) {
+ setDetailSkill(null)
+ }
+ }}
+ onShare={() => {
+ if (detailSkill) {
+ onShare(detailSkill)
+ setDetailSkill(null)
+ }
+ }}
+ />
+
+ {skills.map((skill, index) => {
+ const duplicateNameSelected =
+ !selectedIds.has(skill.id) && selectedNames.has(skill.name.toLocaleLowerCase('en-US'))
+ const eligible = isSkillShareEligible(skill, local)
+ return (
+ setFocusedId(skill.id)}
+ onOpenDetail={() => setDetailSkill(skill)}
+ onSelectionChange={(selected, range) => handleSelection(index, selected, range)}
+ onShare={() => onShare(skill)}
+ onKeyDown={onListKeyDown}
+ />
+ )
+ })}
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillsPage.test.tsx b/src/renderer/src/components/skills/SkillsPage.test.tsx
index ce2ef82ba6c..2c2d650f0db 100644
--- a/src/renderer/src/components/skills/SkillsPage.test.tsx
+++ b/src/renderer/src/components/skills/SkillsPage.test.tsx
@@ -80,14 +80,17 @@ async function flushMicrotasks(): Promise {
})
}
-/** Skill names currently rendered as cards. */
+/** Skill names currently rendered as rows. */
function renderedSkillNames(): string[] {
- return [...(container?.querySelectorAll('h3') ?? [])].map((node) => node.textContent ?? '')
+ return [...(container?.querySelectorAll('[data-skill-name]') ?? [])].map(
+ (node) => node.textContent ?? ''
+ )
}
function buttonNamed(name: string): HTMLButtonElement {
const button = [...(container?.querySelectorAll('button') ?? [])].find(
- (candidate) => candidate.textContent?.trim() === name
+ (candidate) =>
+ candidate.textContent?.trim() === name || candidate.getAttribute('aria-label') === name
)
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Missing button: ${name}`)
@@ -95,6 +98,26 @@ function buttonNamed(name: string): HTMLButtonElement {
return button
}
+function buttonStartingWith(prefix: string): HTMLButtonElement {
+ const button = [...(container?.querySelectorAll('button') ?? [])].find((candidate) =>
+ candidate.textContent?.trim().startsWith(prefix)
+ )
+ if (!(button instanceof HTMLButtonElement)) {
+ throw new Error(`Missing button starting with: ${prefix}`)
+ }
+ return button
+}
+
+function skillRow(name: string): HTMLElement {
+ const row = [...(container?.querySelectorAll('[role="option"]') ?? [])].find(
+ (candidate) => candidate.querySelector('[data-skill-name]')?.textContent === name
+ )
+ if (!(row instanceof HTMLElement)) {
+ throw new Error(`Missing skill row: ${name}`)
+ }
+ return row
+}
+
function selectionCheckbox(name: string): HTMLButtonElement {
const checkbox = container?.querySelector(`[aria-label="Select ${name}"]`)
if (!(checkbox instanceof HTMLButtonElement)) {
@@ -167,11 +190,15 @@ describe('SkillsPage', () => {
await renderPage()
await flushMicrotasks()
+ // Why: the path lives in the detail dialog, where it must wrap inside the
+ // column instead of pushing the dialog into horizontal scroll.
+ await act(async () => fireEvent.click(skillRow('long-path')))
- const path = [...(container?.querySelectorAll('[title]') ?? [])].find(
- (element) => element.getAttribute('title') === longPath
+ const dialog = document.querySelector('[role="dialog"]')
+ const path = [...(dialog?.querySelectorAll('*') ?? [])].find(
+ (element) => element.textContent === longPath && element.children.length === 0
)
- expect(path?.classList.contains('truncate')).toBe(true)
+ expect(path?.classList.contains('break-all')).toBe(true)
expect(path?.textContent).toBe(longPath)
})
@@ -267,7 +294,7 @@ describe('SkillsPage', () => {
await act(async () => fireEvent.input(search, { target: { value: 'beta' } }))
expect(container?.textContent).toContain('1 selected')
- await act(async () => fireEvent.click(buttonNamed('Select all results')))
+ await act(async () => fireEvent.click(buttonStartingWith('Select all')))
expect(container?.textContent).toContain('2 selected')
await act(async () => fireEvent.input(search, { target: { value: '' } }))
expect(selectionCheckbox('alpha').getAttribute('data-state')).toBe('checked')
@@ -294,13 +321,109 @@ describe('SkillsPage', () => {
await act(async () => fireEvent.click(buttonNamed('Share skills')))
expect(container?.textContent).toContain('Only home and workspace skills can be shared.')
- await act(async () => fireEvent.click(buttonNamed('Select all results')))
+ await act(async () => fireEvent.click(buttonStartingWith('Select all')))
expect(container?.textContent).toContain('1 selected')
expect(container?.textContent).toContain(
'A skill with this name is already selected from another source.'
)
})
+ // Why: Escape used to leave the page outright, discarding a selection that can
+ // hold dozens of skills chosen one by one.
+ it('backs out of share selection on Escape before leaving the page', async () => {
+ const closeSkillsPage = vi.fn()
+ const discover = vi.fn().mockResolvedValue(discoveryResult(['alpha']))
+ useAppStore.setState({ closeSkillsPage })
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
+ })
+
+ await renderPage()
+ await flushMicrotasks()
+ await act(async () => fireEvent.click(buttonNamed('Share skills')))
+ expect(container?.querySelector('[aria-label="Select alpha"]')).not.toBeNull()
+
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ })
+ expect(closeSkillsPage).not.toHaveBeenCalled()
+ expect(container?.querySelector('[aria-label="Select alpha"]')).toBeNull()
+
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ })
+ expect(closeSkillsPage).toHaveBeenCalledOnce()
+ })
+
+ // Why: bundles run to ~30 skills; ticking each box one at a time is the flow
+ // this page exists for.
+ it('extends the share selection to a shift-clicked row', async () => {
+ const discover = vi.fn().mockResolvedValue(discoveryResult(['alpha', 'beta', 'gamma']))
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
+ })
+
+ await renderPage()
+ await flushMicrotasks()
+ await act(async () => fireEvent.click(buttonNamed('Share skills')))
+ await act(async () => fireEvent.click(skillRow('alpha')))
+ expect(container?.textContent).toContain('1 selected')
+
+ const gamma = skillRow('gamma')
+ await act(async () => {
+ fireEvent.pointerDown(gamma, { shiftKey: true })
+ fireEvent.click(gamma, { shiftKey: true })
+ })
+ expect(container?.textContent).toContain('3 selected')
+ })
+
+ it('filters by source from the count chips', async () => {
+ const discover = vi.fn().mockResolvedValue({
+ skills: [skill('home-skill'), skill('plugin-skill', { sourceKind: 'plugin' })],
+ sources: [],
+ scannedAt: 1
+ } satisfies SkillDiscoveryResult)
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
+ })
+
+ await renderPage()
+ await flushMicrotasks()
+ await act(async () => fireEvent.click(buttonStartingWith('Plugin')))
+
+ expect(renderedSkillNames()).toEqual(['plugin-skill'])
+ expect(container?.textContent).toContain('1 result')
+ })
+
+ // Why: the reason is per-row state, but on a remote runtime it applies to every
+ // row at once — 114 copies of the same sentence is not an explanation.
+ it('explains remote-only skills once instead of on every row', async () => {
+ const call = vi.fn(
+ async (args: { method: string; selector?: string }) =>
+ createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
+ id: 'skills',
+ ok: true,
+ result: discoveryResult(['remote-one', 'remote-two'])
+ }
+ )
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { skills: skillsApi(vi.fn()), runtimeEnvironments: { call } }
+ })
+ setRuntimeOwner('env-1')
+
+ await renderPage()
+ await flushMicrotasks()
+ await act(async () => fireEvent.click(buttonNamed('Share skills')))
+
+ const notices = (container?.textContent ?? '').split('Open Skills on that machine').length - 1
+ expect(notices).toBe(1)
+ expect(container?.textContent).not.toContain('Open this skill on its owning machine')
+ })
+
it('drops stale selections when a refreshed scan no longer contains the skill', async () => {
const discover = vi
.fn()
diff --git a/src/renderer/src/components/skills/SkillsPage.tsx b/src/renderer/src/components/skills/SkillsPage.tsx
index 556f9814dc3..142c8ddcd6b 100644
--- a/src/renderer/src/components/skills/SkillsPage.tsx
+++ b/src/renderer/src/components/skills/SkillsPage.tsx
@@ -1,106 +1,68 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ArrowLeft, BookOpen, Download, History, Loader2, RefreshCw, Search } from 'lucide-react'
-import { toast } from 'sonner'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue
-} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { discoverSkillsForRuntimeTarget } from '@/runtime/runtime-skills-client'
import { useActiveSkillDiscoveryRuntimeTarget } from '@/hooks/use-active-skill-discovery-runtime-target'
import { useMountedRef } from '@/hooks/useMountedRef'
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
-import { SkillShareSelectionList } from './SkillShareSelectionList'
+import { SkillsList } from './SkillsList'
import { SkillShareDialog } from './SkillShareDialog'
import { SkillInstallDialog } from './SkillInstallDialog'
import { SkillInstallManagementDialog } from './SkillInstallManagementDialog'
-import { pluralize, sourceLabels } from './skill-display-labels'
+import { SkillsPageHeader } from './SkillsPageHeader'
+import { SkillsFilterToolbar } from './SkillsFilterToolbar'
+import { SkillsSelectionHeader } from './SkillsSelectionHeader'
+import {
+ SkillsEmptyState,
+ SkillsListSkeleton,
+ SkillsNoMatchesState,
+ SkillsRemoteShareNotice,
+ SkillsScanErrorBand
+} from './skills-page-states'
+import { SKILLS_PAGE_COLUMN } from './skills-page-column'
+import { scannedSkillSourceCount, summarizeSkillSources } from './skill-source-inventory'
+import { useSkillDiscoveryHostLabel } from './use-skill-discovery-host-label'
import { countSkillsBySource, filterSkills, type SkillsFilterState } from './skills-filter'
+import { skillAgentByRootPath, skillAgentOptions } from './skill-agent-filter'
+import { SkillSharedLinksView } from './SkillSharedLinksView'
+import { useOwnedSkillShares } from './use-owned-skill-shares'
+import type { SkillsPageView } from './skills-page-view'
import { translate } from '@/i18n/i18n'
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from '@/hooks/installed-agent-skills-change-event'
import {
- SkillShareSelectionAction,
- SkillShareSelectionStatus,
addShareableSkillResults,
+ eligibleShareSkillCount,
retainedShareableSkillSelection,
updatedSkillSelection
-} from './SkillShareSelectionControls'
+} from './skill-share-selection'
const EMPTY_SKILLS: DiscoveredSkill[] = []
-
-function EmptyState({
- loading,
- hasSkills,
- onRefresh
-}: {
- loading: boolean
- hasSkills: boolean
- onRefresh: () => void
-}): React.JSX.Element {
- return (
-
-
- {loading ? (
-
- ) : (
-
- )}
-
-
- {loading
- ? translate('auto.components.skills.SkillsPage.cd7893fbc1', 'Scanning skills')
- : hasSkills
- ? translate('auto.components.skills.SkillsPage.6a62a0168c', 'No matches')
- : translate('auto.components.skills.SkillsPage.4acd6d68ec', 'No skills found')}
-
-
- {hasSkills
- ? translate(
- 'auto.components.skills.SkillsPage.08a321a984',
- 'Adjust the search or filters.'
- )
- : translate(
- 'auto.components.skills.SkillsPage.ab5b777350',
- 'Checked home, repository, bundled, and plugin skill folders.'
- )}
-
-
- {!loading ? (
-
-
- {translate('auto.components.skills.SkillsPage.cb142070b4', 'Refresh')}
-
- ) : null}
-
-
- )
+const NO_FILTERS: SkillsFilterState = {
+ query: '',
+ sourceKind: 'all',
+ agent: 'all'
}
export default function SkillsPage(): React.JSX.Element {
const closeSkillsPage = useAppStore((s) => s.closeSkillsPage)
const pendingSkillShareId = useAppStore((s) => s.pendingSkillShareId)
const clearPendingSkillShare = useAppStore((s) => s.clearPendingSkillShare)
+ const pendingSkillsSharedView = useAppStore((s) => s.pendingSkillsSharedView)
+ const clearPendingSkillsSharedView = useAppStore((s) => s.clearPendingSkillsSharedView)
const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
+ const hostLabel = useSkillDiscoveryHostLabel(runtimeTarget)
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(true)
+ const [scanError, setScanError] = useState(null)
const [shareSkills, setShareSkills] = useState([])
const [selectingShare, setSelectingShare] = useState(false)
const [selectedSkillIds, setSelectedSkillIds] = useState>(() => new Set())
const [installOpen, setInstallOpen] = useState(false)
const [installLink, setInstallLink] = useState('')
const [managementOpen, setManagementOpen] = useState(false)
- const [filters, setFilters] = useState({
- query: '',
- sourceKind: 'all',
- provider: 'all'
- })
+ const [filters, setFilters] = useState(NO_FILTERS)
+ const [view, setView] = useState('skills')
+ const ownedShares = useOwnedSkillShares()
const mountedRef = useMountedRef()
const scanGenerationRef = useRef(0)
@@ -121,6 +83,7 @@ export default function SkillsPage(): React.JSX.Element {
const local = runtimeTarget.kind === 'local'
if (isCurrentScan()) {
setResult(nextResult)
+ setScanError(null)
setSelectedSkillIds((current) =>
retainedShareableSkillSelection(current, nextResult.skills, local)
)
@@ -128,7 +91,9 @@ export default function SkillsPage(): React.JSX.Element {
} catch (error) {
console.error('Failed to discover skills:', error)
if (isCurrentScan()) {
- toast.error(
+ // Why: a failed scan needs to stay on screen with a retry — a toast
+ // disappears before the user can act on it.
+ setScanError(
translate('auto.components.skills.SkillsPage.ea72d6185b', 'Could not scan skills')
)
}
@@ -149,6 +114,14 @@ export default function SkillsPage(): React.JSX.Element {
return () => window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refresh)
}, [loadSkills])
+ useEffect(() => {
+ if (pendingSkillsSharedView) {
+ setView('shared')
+ setFilters(NO_FILTERS)
+ clearPendingSkillsSharedView()
+ }
+ }, [clearPendingSkillsSharedView, pendingSkillsSharedView])
+
useEffect(() => {
if (!pendingSkillShareId) {
return
@@ -158,6 +131,23 @@ export default function SkillsPage(): React.JSX.Element {
clearPendingSkillShare()
}, [clearPendingSkillShare, pendingSkillShareId])
+ const exitSelection = useCallback((): void => {
+ setSelectingShare(false)
+ setSelectedSkillIds(new Set())
+ }, [])
+
+ // Why: the search box is shared between the two lists, but a link query means
+ // nothing against skill names and vice versa.
+ const exitSharedLinks = useCallback((): void => {
+ setView('skills')
+ setFilters(NO_FILTERS)
+ }, [])
+
+ const openSharedLinks = useCallback((): void => {
+ setView('shared')
+ setFilters(NO_FILTERS)
+ }, [])
+
useEffect(() => {
const hasVisibleOverlay = (): boolean =>
Array.from(
@@ -169,6 +159,9 @@ export default function SkillsPage(): React.JSX.Element {
if (element.closest('[aria-hidden="true"]')) {
return false
}
+ if (element.closest('[data-skills-page-list="true"]')) {
+ return false
+ }
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
@@ -193,6 +186,18 @@ export default function SkillsPage(): React.JSX.Element {
return
}
event.preventDefault()
+ // Why: leaving the page would silently discard a selection that can hold
+ // dozens of skills, so Escape backs out of the mode first.
+ if (selectingShare) {
+ exitSelection()
+ return
+ }
+ // Why: shared links are a view within the page, so Escape returns to the
+ // skill list before it leaves the page.
+ if (view === 'shared') {
+ exitSharedLinks()
+ return
+ }
closeSkillsPage()
}
@@ -200,206 +205,125 @@ export default function SkillsPage(): React.JSX.Element {
// keeps page-level back navigation reliable when no overlay is active.
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
- }, [closeSkillsPage])
+ }, [closeSkillsPage, exitSelection, exitSharedLinks, selectingShare, view])
const skills = result?.skills ?? EMPTY_SKILLS
- const visibleSkills = useMemo(() => filterSkills(skills, filters), [filters, skills])
+ const local = runtimeTarget?.kind === 'local'
+ const agentByRootPath = useMemo(() => skillAgentByRootPath(result), [result])
+ const agentOptions = useMemo(() => skillAgentOptions(result), [result])
+ const visibleSkills = useMemo(
+ () => filterSkills(skills, filters, agentByRootPath),
+ [agentByRootPath, filters, skills]
+ )
const sourceCounts = useMemo(() => countSkillsBySource(skills), [skills])
+ const sourceEntries = useMemo(() => summarizeSkillSources(result), [result])
+ const eligibleCount = eligibleShareSkillCount(visibleSkills, local)
+ const openInstallDialog = (): void => {
+ setInstallLink('')
+ setInstallOpen(true)
+ }
+
return (
-
-
-
- {translate('auto.components.skills.SkillsPage.7e828fb2c6', 'Back')}
-
-
-
-
-
-
- {translate('auto.components.skills.SkillsPage.f43ad6edf3', 'Skills')}
-
-
- {translate('auto.components.skills.SkillsPage.b088e0785d', 'Beta')}
-
-
-
- {pluralize(skills.length, 'skill')}{' '}
- {translate('auto.components.skills.SkillsPage.e46e162e2e', 'from')}
- {pluralize(result?.sources.filter((source) => source.exists).length ?? 0, 'source')}
-
-
-
- {
- if (selectingShare && selectedSkillIds.size > 0) {
- setShareSkills(skills.filter((skill) => selectedSkillIds.has(skill.id)))
- return
- }
- setSelectingShare((value) => !value)
+ eligibleCount={eligibleCount}
+ onSelectAll={() =>
+ setSelectedSkillIds((current) =>
+ addShareableSkillResults(current, skills, visibleSkills, local)
+ )
+ }
+ onClear={() => setSelectedSkillIds(new Set())}
+ onCancel={exitSelection}
+ onShare={() => setShareSkills(skills.filter((skill) => selectedSkillIds.has(skill.id)))}
+ />
+ ) : (
+ {
+ setSelectingShare(true)
setSelectedSkillIds(new Set())
}}
+ onInstallFromLink={openInstallDialog}
+ onManageInstalls={() => setManagementOpen(true)}
+ onOpenSharedLinks={openSharedLinks}
/>
- {
- setInstallLink('')
- setInstallOpen(true)
- }}
- >
- {' '}
- {translate('auto.components.skills.SkillsPage.aee7b99cc6', 'Install from link')}
-
- setManagementOpen(true)}>
- {' '}
- {translate('auto.components.skills.SkillsPage.c13b82793c', 'Manage installs')}
-
-
+ )}
+ (next === 'shared' ? openSharedLinks() : exitSharedLinks())}
+ onFiltersChange={setFilters}
+ onRefresh={() => (view === 'shared' ? ownedShares.refresh() : void loadSkills())}
+ />
+ {scanError ? (
+ void loadSkills()}
+ />
+ ) : null}
-
- {selectingShare ? (
-
- setSelectedSkillIds((current) =>
- addShareableSkillResults(
- current,
- skills,
- visibleSkills,
- runtimeTarget?.kind === 'local'
- )
- )
- }
- />
- ) : null}
-
-
-
-
setFilters((next) => ({ ...next, query: event.target.value }))}
- placeholder={translate(
- 'auto.components.skills.SkillsPage.a68dee6a32',
- 'Search skills'
+
+
+ {view === 'shared' ? (
+
+ ) : (
+ <>
+ {hostLabel && !local ? : null}
+ {loading && skills.length === 0 ? (
+
+ ) : visibleSkills.length > 0 ? (
+
+ setSelectedSkillIds((current) =>
+ updatedSkillSelection(current, skillId, selected)
+ )
+ }
+ onSelectResults={(results) =>
+ setSelectedSkillIds((current) =>
+ addShareableSkillResults(current, skills, results, local)
+ )
+ }
+ onShare={(skill) => setShareSkills([skill])}
+ />
+ ) : skills.length > 0 ? (
+ setFilters(NO_FILTERS)} />
+ ) : (
+ void loadSkills()}
+ onInstallFromLink={openInstallDialog}
+ />
)}
- className="h-8 pl-8 text-sm"
- />
-
-
-
- setFilters((next) => ({
- ...next,
- provider: value as SkillsFilterState['provider']
- }))
- }
- >
-
-
-
-
-
- {translate('auto.components.skills.SkillsPage.39b6998ddb', 'All providers')}
-
-
- {translate('auto.components.skills.SkillsPage.426be2aac6', 'Codex')}
-
-
- {translate('auto.components.skills.SkillsPage.fb6bf60b52', 'Claude')}
-
-
- {translate('auto.components.skills.SkillsPage.38e0951c3a', 'Agent Skills')}
-
-
-
-
- setFilters((next) => ({
- ...next,
- sourceKind: value as SkillsFilterState['sourceKind']
- }))
- }
- >
-
-
-
-
-
- {translate('auto.components.skills.SkillsPage.0bc1379f4c', 'All sources')}
-
-
- {translate('auto.components.skills.SkillsPage.571c5818c1', 'Home')}
-
-
- {translate('auto.components.skills.SkillsPage.aa59462502', 'Repository')}
-
-
- {translate('auto.components.skills.SkillsPage.4d177feabd', 'Bundled')}
-
-
- {translate('auto.components.skills.SkillsPage.984405683f', 'Plugin')}
-
-
-
- {
- void loadSkills()
- }}
- >
-
- {translate('auto.components.skills.SkillsPage.cb142070b4', 'Refresh')}
-
-
-
-
- {(['home', 'repo', 'bundled', 'plugin'] as const).map((sourceKind) => (
-
- {sourceLabels[sourceKind]} {sourceCounts[sourceKind]}
-
- ))}
+ >
+ )}
-
- {visibleSkills.length > 0 ? (
-
- setSelectedSkillIds((current) => updatedSkillSelection(current, skillId, selected))
- }
- onShare={(skill) => setShareSkills([skill])}
- />
- ) : (
- 0}
- onRefresh={() => void loadSkills()}
- />
- )}
-
0}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShareSkills([])
- setSelectingShare(false)
- setSelectedSkillIds(new Set())
+ exitSelection()
}
}}
/>
diff --git a/src/renderer/src/components/skills/SkillsPageHeader.tsx b/src/renderer/src/components/skills/SkillsPageHeader.tsx
new file mode 100644
index 00000000000..a8817c3ecf4
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillsPageHeader.tsx
@@ -0,0 +1,121 @@
+import { BookOpen, Download, History, Link2, MoreHorizontal, Share2, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { skillCountLabel } from './skill-display-labels'
+import { SKILLS_PAGE_COLUMN } from './skills-page-column'
+import { SkillsSourcesPopover } from './SkillsSourcesPopover'
+import type { SkillSourceInventoryEntry } from './skill-source-inventory'
+
+export function SkillsPageHeader({
+ skillCount,
+ sourceEntries,
+ scannedSourceCount,
+ hostLabel,
+ onClose,
+ onStartShare,
+ onInstallFromLink,
+ onManageInstalls,
+ onOpenSharedLinks
+}: {
+ skillCount: number
+ sourceEntries: readonly SkillSourceInventoryEntry[]
+ scannedSourceCount: number
+ hostLabel: string | null
+ onClose: () => void
+ onStartShare: () => void
+ onInstallFromLink: () => void
+ onManageInstalls: () => void
+ onOpenSharedLinks: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+ {translate('auto.components.skills.SkillsPage.closeTooltip', 'Close · Esc')}
+
+
+
+
+
+
+ {translate('auto.components.skills.SkillsPage.f43ad6edf3', 'Skills')}
+
+
+ {skillCountLabel(skillCount)}
+ {sourceEntries.length > 0 ? (
+ <>
+ ·
+
+ >
+ ) : null}
+ {hostLabel ? (
+ <>
+ ·
+ {hostLabel}
+ >
+ ) : null}
+
+
+
+
+ {translate(
+ 'auto.components.skills.SkillShareSelectionControls.01c5a15e02',
+ 'Share skills'
+ )}
+
+
+
+ {translate('auto.components.skills.SkillsPage.aee7b99cc6', 'Install from link')}
+
+
+
+
+
+
+
+
+
+
+ {translate('auto.components.skills.SkillsPage.c13b82793c', 'Manage installs')}
+
+
+
+ {translate('auto.components.skills.SkillsPage.sharedLinks', 'Shared links')}
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillsSelectionHeader.tsx b/src/renderer/src/components/skills/SkillsSelectionHeader.tsx
new file mode 100644
index 00000000000..c6269348751
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillsSelectionHeader.tsx
@@ -0,0 +1,101 @@
+import { Share2, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { selectedCountLabel, shareSelectionActionLabel } from './skill-display-labels'
+import { SKILLS_PAGE_COLUMN } from './skills-page-column'
+import { SKILLS_SUBTITLE_ACTION_CLASS } from './skills-subtitle-action'
+
+/**
+ * Selection takes over the page header instead of opening a bar somewhere else:
+ * the submit lands in the slot the user just clicked, and `✕` keeps meaning what
+ * Esc means — leave the mode, not the page.
+ */
+export function SkillsSelectionHeader({
+ selectedCount,
+ eligibleCount,
+ onSelectAll,
+ onClear,
+ onCancel,
+ onShare
+}: {
+ selectedCount: number
+ eligibleCount: number
+ onSelectAll: () => void
+ onClear: () => void
+ onCancel: () => void
+ onShare: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+ {translate(
+ 'auto.components.skills.SkillsSelectionHeader.exitTooltip',
+ 'Exit selection · Esc'
+ )}
+
+
+
+
+
+
+ {translate(
+ 'auto.components.skills.SkillsSelectionHeader.title',
+ 'Select skills to share'
+ )}
+
+
+
+ {selectedCountLabel(selectedCount)}
+
+ ·
+
+ {translate(
+ 'auto.components.skills.SkillsSelectionHeader.selectAll',
+ 'Select all {{count}} eligible',
+ { count: eligibleCount }
+ )}
+
+ ·
+
+ {translate('auto.components.skills.SkillsSelectionHeader.clear', 'Clear')}
+
+
+
+
+
+ {shareSelectionActionLabel(selectedCount)}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/SkillsSourcesPopover.tsx b/src/renderer/src/components/skills/SkillsSourcesPopover.tsx
new file mode 100644
index 00000000000..487d9c76e1d
--- /dev/null
+++ b/src/renderer/src/components/skills/SkillsSourcesPopover.tsx
@@ -0,0 +1,70 @@
+import { ChevronDown } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { translate } from '@/i18n/i18n'
+import { sourceCountLabel } from './skill-display-labels'
+import { SKILLS_SUBTITLE_ACTION_CLASS } from './skills-subtitle-action'
+import type { SkillSourceInventoryEntry, SkillSourceStatus } from './skill-source-inventory'
+
+function statusLabel(status: SkillSourceStatus): string | null {
+ switch (status) {
+ case 'scanned':
+ return null
+ case 'missing':
+ return translate('auto.components.skills.sourceStatus.missing', 'Folder not found')
+ case 'remote-repo':
+ return translate(
+ 'auto.components.skills.sourceStatus.remoteRepo',
+ 'Remote repo — not scanned'
+ )
+ case 'unavailable':
+ return translate('auto.components.skills.sourceStatus.unavailable', 'Not scanned')
+ }
+}
+
+export function SkillsSourcesPopover({
+ entries,
+ scannedCount
+}: {
+ entries: readonly SkillSourceInventoryEntry[]
+ scannedCount: number
+}): React.JSX.Element {
+ return (
+
+
+
+ {sourceCountLabel(scannedCount)}
+
+
+
+
+
+ {translate('auto.components.skills.sources.heading', 'Skill folders')}
+
+
+ {entries.map((entry) => {
+ const status = statusLabel(entry.status)
+ return (
+
+
+
+ {entry.source.label}
+
+
+ {status ?? entry.skillCount}
+
+
+
+ {entry.source.path}
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/skills/skill-agent-filter.ts b/src/renderer/src/components/skills/skill-agent-filter.ts
new file mode 100644
index 00000000000..78c35057137
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-agent-filter.ts
@@ -0,0 +1,58 @@
+import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
+import { AI_VAULT_AGENT_LABELS } from '../../../../shared/ai-vault-types'
+import { translate } from '@/i18n/i18n'
+
+/** Roots like `.agents/skills` that every agent reads, so no single one owns them. */
+export const SHARED_SKILL_AGENT = 'shared'
+
+export type SkillAgentOption = { id: string; label: string; count: number }
+
+export function skillAgentLabel(agentId: string): string {
+ if (agentId === SHARED_SKILL_AGENT) {
+ return translate('auto.components.skills.filter.sharedAgent', 'Shared (.agents)')
+ }
+ return (AI_VAULT_AGENT_LABELS as Record)[agentId] ?? agentId
+}
+
+/**
+ * Discovery tags every non-Codex, non-Claude root as `agent-skills` and keeps
+ * the real agent on the source, so the owning agent has to come from the root
+ * a skill was found in rather than from its provider list.
+ */
+export function skillAgentByRootPath(
+ result: SkillDiscoveryResult | null
+): ReadonlyMap {
+ return new Map(
+ (result?.sources ?? []).map((source) => [source.path, source.owner ?? SHARED_SKILL_AGENT])
+ )
+}
+
+function skillAgents(
+ skill: DiscoveredSkill,
+ agentByRootPath: ReadonlyMap
+): string[] {
+ const roots = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath]
+ return [...new Set(roots.map((root) => agentByRootPath.get(root)).filter(Boolean))] as string[]
+}
+
+export function skillMatchesAgent(
+ skill: DiscoveredSkill,
+ agentId: string,
+ agentByRootPath: ReadonlyMap
+): boolean {
+ return agentId === 'all' || skillAgents(skill, agentByRootPath).includes(agentId)
+}
+
+/** Only agents that actually hold a skill; an empty root is not a filter. */
+export function skillAgentOptions(result: SkillDiscoveryResult | null): SkillAgentOption[] {
+ const agentByRootPath = skillAgentByRootPath(result)
+ const counts = new Map()
+ for (const skill of result?.skills ?? []) {
+ for (const agent of skillAgents(skill, agentByRootPath)) {
+ counts.set(agent, (counts.get(agent) ?? 0) + 1)
+ }
+ }
+ return [...counts.entries()]
+ .map(([id, count]) => ({ id, label: skillAgentLabel(id), count }))
+ .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label))
+}
diff --git a/src/renderer/src/components/skills/skill-bundle-name.test.ts b/src/renderer/src/components/skills/skill-bundle-name.test.ts
new file mode 100644
index 00000000000..587de88b3d0
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-bundle-name.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import type { DiscoveredSkill } from '../../../../shared/skills'
+import { SkillBundleManifestV1Schema } from '../../../../shared/skill-bundle-manifest'
+import { derivedBundleName } from './skill-bundle-name'
+
+function skill(name: string): DiscoveredSkill {
+ return {
+ id: `skill-${name}`,
+ name,
+ description: null,
+ providers: ['agent-skills'],
+ sourceKind: 'home',
+ sourceLabel: 'Agent skills home',
+ rootPath: '/home/dev/.agents/skills',
+ directoryPath: `/home/dev/.agents/skills/${name}`,
+ skillFilePath: `/home/dev/.agents/skills/${name}/SKILL.md`,
+ installed: true,
+ fileCount: 1,
+ updatedAt: null
+ }
+}
+
+/** The publish call feeds this straight into the manifest, so anything this
+ * returns has to survive the schema that rejected `--`, uppercase, and >64. */
+function isPublishable(name: string): boolean {
+ return SkillBundleManifestV1Schema.shape.bundleName.safeParse(name).success
+}
+
+describe('derivedBundleName', () => {
+ it('keeps a single skill name as the bundle name', () => {
+ expect(derivedBundleName([skill('agent-discord')])).toBe('agent-discord')
+ })
+
+ it('distinguishes bundles by their first skill and how many others ride along', () => {
+ const bundle = [skill('agent-discord'), skill('agent-slack'), skill('orca-cli')]
+ expect(derivedBundleName(bundle)).toBe('agent-discord-and-2-more')
+ })
+
+ it('publishes distinct names for distinct bundles', () => {
+ const first = derivedBundleName([skill('alpha'), skill('beta')])
+ const second = derivedBundleName([skill('gamma'), skill('delta')])
+ expect(first).not.toBe(second)
+ })
+
+ it('slugifies names the bundle schema would reject', () => {
+ const name = derivedBundleName([skill('My Skill_v2'), skill('other')])
+ expect(name).toBe('my-skill-v2-and-1-more')
+ expect(isPublishable(name)).toBe(true)
+ })
+
+ it('stays within the 64-character limit without a trailing separator', () => {
+ const name = derivedBundleName([skill('a'.repeat(80)), skill('other')])
+ expect(name.length).toBeLessThanOrEqual(64)
+ expect(isPublishable(name)).toBe(true)
+ })
+
+ it('falls back when a name slugifies to nothing', () => {
+ expect(derivedBundleName([skill('///'), skill('other')])).toBe('shared-skills')
+ expect(derivedBundleName([])).toBe('shared-skills')
+ })
+})
diff --git a/src/renderer/src/components/skills/skill-bundle-name.ts b/src/renderer/src/components/skills/skill-bundle-name.ts
new file mode 100644
index 00000000000..a121c3d0f76
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-bundle-name.ts
@@ -0,0 +1,34 @@
+import type { DiscoveredSkill } from '../../../../shared/skills'
+
+/** Mirrors `PLUGIN_NAME_PATTERN` in `skill-bundle-manifest.ts`: lowercase
+ * alphanumerics plus `.`/`-`, no `--`/`..`, no leading or trailing separator. */
+const MAX_BUNDLE_NAME = 64
+const FALLBACK_BUNDLE_NAME = 'shared-skills'
+
+function slugify(value: string): string {
+ return value
+ .toLocaleLowerCase('en-US')
+ .replace(/[^a-z0-9.-]+/g, '-')
+ .replace(/[-.]{2,}/g, '-')
+ .replace(/^[-.]+|[-.]+$/g, '')
+}
+
+/**
+ * Every multi-skill bundle used to publish as `shared-skills`, so a list of
+ * links showed the same name N times. Naming a bundle after its first skill and
+ * how many others ride along makes each link identifiable without asking the
+ * publisher for a name.
+ */
+export function derivedBundleName(skills: readonly DiscoveredSkill[]): string {
+ const first = slugify(skills[0]?.name ?? '')
+ if (!first) {
+ return FALLBACK_BUNDLE_NAME
+ }
+ const others = skills.length - 1
+ if (others <= 0) {
+ return first.slice(0, MAX_BUNDLE_NAME).replace(/[-.]+$/g, '') || FALLBACK_BUNDLE_NAME
+ }
+ const suffix = `-and-${others}-more`
+ const head = first.slice(0, MAX_BUNDLE_NAME - suffix.length).replace(/[-.]+$/g, '')
+ return head ? `${head}${suffix}` : FALLBACK_BUNDLE_NAME
+}
diff --git a/src/renderer/src/components/skills/skill-description-length.test.ts b/src/renderer/src/components/skills/skill-description-length.test.ts
new file mode 100644
index 00000000000..e99288d01d3
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-description-length.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { isLongSkillDescription, SKILL_DESCRIPTION_CLAMP_CHARS } from './skill-description-length'
+
+describe('isLongSkillDescription', () => {
+ it('leaves a description that fits the clamp alone', () => {
+ expect(isLongSkillDescription('Interact with Discord servers.')).toBe(false)
+ expect(isLongSkillDescription('a'.repeat(SKILL_DESCRIPTION_CLAMP_CHARS))).toBe(false)
+ })
+
+ it('offers the toggle once a description runs past the clamp', () => {
+ expect(isLongSkillDescription('a'.repeat(SKILL_DESCRIPTION_CLAMP_CHARS + 1))).toBe(true)
+ })
+
+ // Why: trigger-word lists pad descriptions with whitespace; padding alone is
+ // not a reason to hide the text behind a toggle.
+ it('ignores surrounding whitespace and missing values', () => {
+ expect(isLongSkillDescription(` ${'a'.repeat(20)} `)).toBe(false)
+ expect(isLongSkillDescription(null)).toBe(false)
+ expect(isLongSkillDescription(undefined)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/skills/skill-description-length.ts b/src/renderer/src/components/skills/skill-description-length.ts
new file mode 100644
index 00000000000..96735aa7952
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-description-length.ts
@@ -0,0 +1,8 @@
+/** Roughly two clamped lines at the width these dialogs use. Longer than this
+ * and the description gets a Show more toggle instead of burying the controls
+ * under a wall of prose. */
+export const SKILL_DESCRIPTION_CLAMP_CHARS = 160
+
+export function isLongSkillDescription(description: string | null | undefined): boolean {
+ return (description?.trim().length ?? 0) > SKILL_DESCRIPTION_CLAMP_CHARS
+}
diff --git a/src/renderer/src/components/skills/skill-display-labels.ts b/src/renderer/src/components/skills/skill-display-labels.ts
index 9a2dd104be8..d3e99da6682 100644
--- a/src/renderer/src/components/skills/skill-display-labels.ts
+++ b/src/renderer/src/components/skills/skill-display-labels.ts
@@ -1,12 +1,69 @@
import type { SkillSourceKind } from '../../../../shared/skills'
+import { translate } from '@/i18n/i18n'
-export const sourceLabels: Record = {
- home: 'Home',
- repo: 'Repository',
- bundled: 'Bundled',
- plugin: 'Plugin'
+// Why: 'repo' roots also come from folder workspaces, so "Repository" misnames
+// half of them; "Workspace" matches the share-eligibility copy users read next.
+export function sourceKindLabel(kind: SkillSourceKind): string {
+ switch (kind) {
+ case 'home':
+ return translate('auto.components.skills.sourceKind.home', 'Home')
+ case 'repo':
+ return translate('auto.components.skills.sourceKind.workspace', 'Workspace')
+ case 'bundled':
+ return translate('auto.components.skills.sourceKind.bundled', 'Bundled')
+ case 'plugin':
+ return translate('auto.components.skills.sourceKind.plugin', 'Plugin')
+ }
}
-export function pluralize(count: number, singular: string): string {
- return `${count} ${singular}${count === 1 ? '' : 's'}`
+export function skillCountLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.count.skillOne', '{{count}} skill', { count })
+ : translate('auto.components.skills.count.skillOther', '{{count}} skills', { count })
+}
+
+export function sourceCountLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.count.sourceOne', '{{count}} source', { count })
+ : translate('auto.components.skills.count.sourceOther', '{{count}} sources', { count })
+}
+
+export function fileCountLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.count.fileOne', '{{count}} file', { count })
+ : translate('auto.components.skills.count.fileOther', '{{count}} files', { count })
+}
+
+export function shareLinkCountLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.count.linkOne', '{{count}} link', { count })
+ : translate('auto.components.skills.count.linkOther', '{{count}} links', { count })
+}
+
+export function resultCountLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.count.resultOne', '{{count}} result', { count })
+ : translate('auto.components.skills.count.resultOther', '{{count}} results', { count })
+}
+
+export function selectedCountLabel(count: number): string {
+ return translate('auto.components.skills.count.selected', '{{count}} selected', { count })
+}
+
+export function shareSelectionActionLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.count.shareOne', 'Share {{count}} skill', { count })
+ : translate('auto.components.skills.count.shareOther', 'Share {{count}} skills', { count })
+}
+
+export function installSkillsActionLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.count.installOne', 'Install {{count}} skill', { count })
+ : translate('auto.components.skills.count.installOther', 'Install {{count}} skills', { count })
+}
+
+export function retrySkillsActionLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.count.retryOne', 'Retry {{count}} skill', { count })
+ : translate('auto.components.skills.count.retryOther', 'Retry {{count}} skills', { count })
}
diff --git a/src/renderer/src/components/skills/skill-install-management-copy.ts b/src/renderer/src/components/skills/skill-install-management-copy.ts
index 893514d4060..c37b2fbf564 100644
--- a/src/renderer/src/components/skills/skill-install-management-copy.ts
+++ b/src/renderer/src/components/skills/skill-install-management-copy.ts
@@ -4,11 +4,11 @@ export function skillInstallManagementCopy() {
return {
title: translate(
'auto.components.skills.SkillInstallManagementDialog.44d118a8f7',
- 'Manage installed skills'
+ 'Installed by Orca'
),
description: translate(
'auto.components.skills.SkillInstallManagementDialog.3677ae58e7',
- 'Update, roll back, or safely remove versions installed by Orca.'
+ 'Skills Orca installed from a link. Reinstall, go back to an earlier version, or remove them.'
),
localMachine: translate(
'auto.components.skills.SkillInstallManagementDialog.6cb1fbe039',
@@ -21,75 +21,8 @@ export function skillInstallManagementCopy() {
),
noInstalls: translate(
'auto.components.skills.SkillInstallManagementDialog.64c71cf7b9',
- 'No Orca-managed skill installs were found on this machine.'
+ 'Orca has not installed any skills on this machine yet.'
),
- installedVersion: translate(
- 'auto.components.skills.SkillInstallManagementDialog.9c04bd0120',
- 'Installed version'
- ),
- chooseVersion: translate(
- 'auto.components.skills.SkillInstallManagementDialog.86ed219b55',
- 'Choose a version'
- ),
- modified: translate(
- 'auto.components.skills.SkillInstallManagementDialog.74b70892ea',
- 'Local files were modified'
- ),
- preserveModified: translate(
- 'auto.components.skills.SkillInstallManagementDialog.070654c6d1',
- 'Orca will preserve them unless you explicitly discard the local changes.'
- ),
- discardAndInstall: translate(
- 'auto.components.skills.SkillInstallManagementDialog.e1884c812e',
- 'Discard changes and install version'
- ),
- discardAndRemove: translate(
- 'auto.components.skills.SkillInstallManagementDialog.f7c5075e77',
- 'Discard changes and remove'
- ),
- retryCoverage: translate(
- 'auto.components.skills.SkillInstallManagementDialog.2ae587d39c',
- 'Retry incomplete coverage'
- ),
- installVersion: translate(
- 'auto.components.skills.SkillInstallManagementDialog.561e49ccd1',
- 'Install selected version'
- ),
- installAnotherMachine: translate(
- 'auto.components.skills.SkillInstallManagementDialog.installAnotherMachine',
- 'Install on another machine'
- ),
- cancelInstall: translate(
- 'auto.components.skills.SkillInstallManagementDialog.c1d03ee50d',
- 'Cancel installation'
- ),
- confirmRemove: translate(
- 'auto.components.skills.SkillInstallManagementDialog.470d8d2476',
- 'Confirm remove'
- ),
- remove: translate('auto.components.skills.SkillInstallManagementDialog.e91af0079f', 'Remove'),
- installedBundleSkills: translate(
- 'auto.components.skills.SkillInstallManagementDialog.1c9e7f420a',
- 'Installed bundle skills'
- ),
- installSkills: (count: number) =>
- translate(
- 'auto.components.skills.SkillInstallManagementDialog.34c2ef9e71',
- 'Install {{count}} skills',
- { count }
- ),
- removeSkills: (count: number) =>
- translate(
- 'auto.components.skills.SkillInstallManagementDialog.a0cab67c2f',
- 'Remove {{count}} skills',
- { count }
- ),
- confirmRemoveSkills: (count: number) =>
- translate(
- 'auto.components.skills.SkillInstallManagementDialog.f970d8088d',
- 'Confirm remove {{count}} skills',
- { count }
- ),
bundleResult: (installed: number, updated: number, keptLocal: number) =>
translate(
'auto.components.skills.SkillInstallManagementDialog.dab29e4b54',
diff --git a/src/renderer/src/components/skills/skill-install-provider-groups.test.ts b/src/renderer/src/components/skills/skill-install-provider-groups.test.ts
new file mode 100644
index 00000000000..7a7a170ca92
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-install-provider-groups.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest'
+import {
+ defaultSelectedSkillProviders,
+ groupSkillInstallProviders,
+ skillProviderDirectoryLabel,
+ toggledSkillProviderSelection
+} from './skill-install-provider-groups'
+
+describe('groupSkillInstallProviders', () => {
+ // Why: an agent that reads .agents/skills is reached by every install, so
+ // offering a checkbox for it would imply a choice that does not exist.
+ it('separates agents that read the canonical root from agents with their own', () => {
+ const workspace = groupSkillInstallProviders('workspace')
+ const global = groupSkillInstallProviders('global')
+ expect(workspace.canonical.map((provider) => provider.id)).toContain('codex')
+ expect(global.canonical.map((provider) => provider.id)).toContain('codex')
+ expect(workspace.selectable.map((choice) => choice.provider.id)).toContain('claude')
+ expect(workspace.selectable.map((choice) => choice.provider.id)).not.toContain('codex')
+ expect(global.selectable.map((choice) => choice.provider.id)).not.toContain('codex')
+ })
+
+ it('moves an agent between the groups when the scope changes', () => {
+ expect(groupSkillInstallProviders('workspace').canonical.map((p) => p.id)).toContain('cursor')
+ expect(groupSkillInstallProviders('global').selectable.map((c) => c.provider.id)).toContain(
+ 'cursor'
+ )
+ })
+
+ it('labels a directory relative to the scope it is shown under', () => {
+ expect(skillProviderDirectoryLabel(['.claude', 'skills'], 'workspace')).toBe('.claude/skills')
+ expect(skillProviderDirectoryLabel(['.claude', 'skills'], 'global')).toBe('~/.claude/skills')
+ })
+})
+
+describe('defaultSelectedSkillProviders', () => {
+ it('starts with the agents the machine actually has', () => {
+ const selected = defaultSelectedSkillProviders(['claude', 'codex', 'unrelated-tool'])
+ expect([...selected]).toEqual(['codex', 'claude'])
+ })
+
+ // Why: a runtime Orca cannot probe from here must not read as "no agents
+ // installed" — that would quietly install nothing.
+ it('falls back to every agent when detection is unavailable', () => {
+ const selected = defaultSelectedSkillProviders(null)
+ expect(selected.has('claude')).toBe(true)
+ expect(selected.has('aug')).toBe(true)
+ })
+
+ it('selects nothing extra when the machine has no known agent', () => {
+ expect([...defaultSelectedSkillProviders([])]).toEqual([])
+ })
+
+ it('adds and removes one agent at a time', () => {
+ const withoutClaude = toggledSkillProviderSelection(
+ defaultSelectedSkillProviders(null),
+ 'claude',
+ false
+ )
+ expect(withoutClaude.has('claude')).toBe(false)
+ expect(withoutClaude.has('codex')).toBe(true)
+ expect(toggledSkillProviderSelection(withoutClaude, 'claude', true).has('claude')).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/skills/skill-install-provider-groups.ts b/src/renderer/src/components/skills/skill-install-provider-groups.ts
new file mode 100644
index 00000000000..fd0b571ba33
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-install-provider-groups.ts
@@ -0,0 +1,85 @@
+import {
+ SKILL_INSTALL_PROVIDERS,
+ type SkillInstallProviderDefinition,
+ type SkillInstallProviderId
+} from '../../../../shared/skill-install-providers'
+
+export type SkillInstallProviderChoice = {
+ provider: SkillInstallProviderDefinition
+ /** Where this agent reads skills from, relative to the chosen scope. */
+ directory: string
+}
+
+export type SkillInstallProviderGroups = {
+ /** Agents that read the canonical `.agents/skills` root, so every install
+ * reaches them and there is nothing to choose. */
+ canonical: SkillInstallProviderDefinition[]
+ selectable: SkillInstallProviderChoice[]
+}
+
+export function skillProviderDirectoryLabel(
+ segments: readonly string[],
+ scope: 'global' | 'workspace'
+): string {
+ const joined = segments.join('/')
+ return scope === 'global' ? `~/${joined}` : joined
+}
+
+export function groupSkillInstallProviders(
+ scope: 'global' | 'workspace',
+ detectedAgents: readonly string[] | null = null
+): SkillInstallProviderGroups {
+ const canonical: SkillInstallProviderDefinition[] = []
+ const selectable: SkillInstallProviderChoice[] = []
+ for (const provider of SKILL_INSTALL_PROVIDERS) {
+ const segments = scope === 'global' ? provider.globalSegments : provider.workspaceSegments
+ if (segments) {
+ selectable.push({ provider, directory: skillProviderDirectoryLabel(segments, scope) })
+ } else {
+ canonical.push(provider)
+ }
+ }
+ if (!detectedAgents) {
+ return { canonical, selectable }
+ }
+ // Why: agents the machine does not have are still offered — you may install
+ // one later — but they belong under the ones the choice actually affects.
+ const installed = new Set(detectedAgents)
+ return {
+ canonical,
+ selectable: [
+ ...selectable.filter((choice) => installed.has(choice.provider.id)),
+ ...selectable.filter((choice) => !installed.has(choice.provider.id))
+ ]
+ }
+}
+
+/**
+ * Starts with the agents the target machine actually has, so the common case is
+ * one click. A null detection (a runtime that cannot be probed from here) falls
+ * back to every agent, which is what installing did before the picker existed.
+ */
+export function defaultSelectedSkillProviders(
+ detectedAgents: readonly string[] | null
+): Set {
+ const ids = SKILL_INSTALL_PROVIDERS.map((provider) => provider.id)
+ if (!detectedAgents) {
+ return new Set(ids)
+ }
+ const detected = new Set(detectedAgents)
+ return new Set(ids.filter((id) => detected.has(id)))
+}
+
+export function toggledSkillProviderSelection(
+ current: ReadonlySet,
+ provider: SkillInstallProviderId,
+ selected: boolean
+): Set {
+ const next = new Set(current)
+ if (selected) {
+ next.add(provider)
+ } else {
+ next.delete(provider)
+ }
+ return next
+}
diff --git a/src/renderer/src/components/skills/skill-managed-install-labels.ts b/src/renderer/src/components/skills/skill-managed-install-labels.ts
new file mode 100644
index 00000000000..27d94cfd837
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-managed-install-labels.ts
@@ -0,0 +1,82 @@
+import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
+import { translate } from '@/i18n/i18n'
+import { groupInstallState, type SkillManagedInstallGroup } from './skill-managed-install-groups'
+
+const dateFormatter = new Intl.DateTimeFormat(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+})
+
+function formatDate(value: string): string | null {
+ const parsed = new Date(value)
+ return Number.isNaN(parsed.getTime()) ? null : dateFormatter.format(parsed)
+}
+
+/** Bundles have no name until the package loads, so the row names itself after
+ * what it actually put on disk. */
+export function managedInstallTitle(group: SkillManagedInstallGroup): string {
+ const [first, ...rest] = group.installs
+ if (!first) {
+ return group.packageId
+ }
+ return rest.length === 0
+ ? first.name
+ : translate('auto.components.skills.managedInstall.titleMore', '{{name}} +{{count}}', {
+ name: first.name,
+ count: rest.length
+ })
+}
+
+export function managedInstallScopeLabel(group: SkillManagedInstallGroup): string {
+ return group.installs[0]?.scope === 'workspace'
+ ? translate('auto.components.skills.managedInstall.scopeWorkspace', 'This workspace')
+ : translate('auto.components.skills.managedInstall.scopeGlobal', 'Everywhere')
+}
+
+export function managedInstalledOnLabel(group: SkillManagedInstallGroup): string | null {
+ const installedAt = group.installs[0]?.installedAt
+ const date = installedAt ? formatDate(installedAt) : null
+ return date
+ ? translate('auto.components.skills.managedInstall.installedOn', 'Installed {{date}}', { date })
+ : null
+}
+
+/**
+ * `unchanged` is the normal case and saying so on every row is noise, so only
+ * the two states a person can act on get words.
+ */
+export function managedInstallStateNote(group: SkillManagedInstallGroup): string | null {
+ const state = groupInstallState(group)
+ if (state === 'modified') {
+ return translate('auto.components.skills.managedInstall.stateEdited', 'Edited after installing')
+ }
+ return state === 'missing'
+ ? translate('auto.components.skills.managedInstall.stateMissing', 'Files are missing')
+ : null
+}
+
+export function managedSkillStateNote(state: string): string | null {
+ if (state === 'modified') {
+ return translate('auto.components.skills.managedInstall.skillEdited', 'Edited')
+ }
+ return state === 'missing'
+ ? translate('auto.components.skills.managedInstall.skillMissing', 'Missing')
+ : null
+}
+
+/** Version ids are opaque, so the date does the identifying and the installed
+ * one is marked rather than spelled out a second time. */
+export function managedVersionLabel(
+ version: SkillCloudVersion,
+ installedVersionId: string
+): string {
+ const date =
+ formatDate(version.createdAt) ??
+ translate('auto.components.skills.SkillRow.updatedUnknown', 'No date')
+ return version.versionId === installedVersionId
+ ? translate('auto.components.skills.managedInstall.versionCurrent', '{{date}} (installed)', {
+ date
+ })
+ : date
+}
diff --git a/src/renderer/src/components/skills/skill-package-checklist-items.test.ts b/src/renderer/src/components/skills/skill-package-checklist-items.test.ts
new file mode 100644
index 00000000000..68b9f56bef5
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-package-checklist-items.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest'
+import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
+import { checklistItemSummary, checklistItemsFromVersion } from './skill-package-checklist-items'
+
+type ManifestFile = {
+ path: string
+ size: number
+ executable: boolean
+ classification: 'binary' | 'text'
+ sha256: string
+ identitySha256: string
+}
+
+function file(path: string, executable = false): ManifestFile {
+ return {
+ path,
+ size: 1024,
+ executable,
+ classification: 'text' as const,
+ sha256: 'a'.repeat(64),
+ identitySha256: 'a'.repeat(64)
+ }
+}
+
+function singleVersion(): SkillCloudVersion {
+ return {
+ packageId: 'pkg_1',
+ versionId: 'ver_1',
+ name: 'published-name',
+ description: 'Published description',
+ packageDigest: 'a'.repeat(64),
+ archiveSha256: 'b'.repeat(64),
+ compressedBytes: 128,
+ createdAt: '2026-08-11T00:00:00.000Z',
+ releaseNotes: '',
+ manifest: {
+ schemaVersion: 1,
+ packageId: 'pkg_1',
+ versionId: 'ver_1',
+ name: 'manifest-name',
+ description: 'Manifest description',
+ createdAt: '2026-08-11T00:00:00.000Z',
+ files: [file('SKILL.md')],
+ packageDigest: 'a'.repeat(64)
+ }
+ }
+}
+
+describe('checklistItemsFromVersion', () => {
+ // Why: one shape for both package kinds keeps the dialog's list identical
+ // whether the link holds one skill or thirty.
+ it('turns a single-skill package into a one-row checklist', () => {
+ expect(checklistItemsFromVersion(singleVersion())).toEqual([
+ {
+ id: 'manifest-name',
+ name: 'published-name',
+ description: 'Published description',
+ files: [file('SKILL.md')]
+ }
+ ])
+ })
+
+ it('keeps each bundled skill as its own row', () => {
+ const bundled = {
+ ...singleVersion(),
+ manifest: {
+ schemaVersion: 1 as const,
+ packageId: 'pkg_1',
+ versionId: 'ver_1',
+ bundleName: 'shared-skills',
+ description: 'Synthetic bundle description',
+ createdAt: '2026-08-11T00:00:00.000Z',
+ bundleDigest: 'c'.repeat(64),
+ skills: [
+ {
+ id: 'a',
+ name: 'alpha',
+ description: 'Alpha',
+ digest: 'd'.repeat(64),
+ files: [file('SKILL.md')]
+ },
+ {
+ id: 'b',
+ name: 'beta',
+ description: 'Beta',
+ digest: 'e'.repeat(64),
+ files: [file('SKILL.md')]
+ }
+ ]
+ }
+ } as unknown as SkillCloudVersion
+ expect(checklistItemsFromVersion(bundled).map((item) => item.name)).toEqual(['alpha', 'beta'])
+ })
+})
+
+describe('checklistItemSummary', () => {
+ it('says once when nothing in the skill can run', () => {
+ expect(checklistItemSummary([file('SKILL.md')])).toEqual({
+ label: '1 file · No scripts or executables',
+ risky: false
+ })
+ })
+
+ it('flags the row when a file can run', () => {
+ expect(checklistItemSummary([file('SKILL.md'), file('scripts/setup.sh')])).toEqual({
+ label: '2 files · 1 script',
+ risky: true
+ })
+ expect(checklistItemSummary([file('bin/tool', true)]).risky).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/skills/skill-package-checklist-items.ts b/src/renderer/src/components/skills/skill-package-checklist-items.ts
new file mode 100644
index 00000000000..2eaefebd48a
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-package-checklist-items.ts
@@ -0,0 +1,50 @@
+import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
+import { fileCountLabel } from './skill-display-labels'
+import { summarizeExecutableContent } from './skill-share-preview-summary'
+
+export type SkillChecklistFile = { path: string; size: number; executable: boolean }
+
+export type SkillChecklistItem = {
+ id: string
+ name: string
+ description: string
+ files: readonly SkillChecklistFile[]
+}
+
+/** One shape for both package kinds: a single skill is a one-item checklist. */
+export function checklistItemsFromVersion(version: SkillCloudVersion): SkillChecklistItem[] {
+ if ('skills' in version.manifest) {
+ return version.manifest.skills.map((skill) => ({
+ id: skill.id,
+ name: skill.name,
+ description: skill.description,
+ files: skill.files
+ }))
+ }
+ return [
+ {
+ id: version.manifest.name,
+ // Why: the version carries the published name and description; the
+ // manifest copy is the fallback for packages that predate them.
+ name: version.name || version.manifest.name,
+ description: version.description || version.manifest.description,
+ files: version.manifest.files
+ }
+ ]
+}
+
+export function isScriptFile(file: SkillChecklistFile): boolean {
+ return file.path.startsWith('scripts/')
+}
+
+/** Row summary: how much is here, and whether any of it can run. */
+export function checklistItemSummary(files: readonly SkillChecklistFile[]): {
+ label: string
+ risky: boolean
+} {
+ const risk = summarizeExecutableContent(
+ files.filter(isScriptFile).length,
+ files.filter((file) => file.executable).length
+ )
+ return { label: `${fileCountLabel(files.length)} · ${risk.label}`, risky: risk.risky }
+}
diff --git a/src/renderer/src/components/skills/skill-package-digest.test.ts b/src/renderer/src/components/skills/skill-package-digest.test.ts
new file mode 100644
index 00000000000..7735694d6a7
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-package-digest.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest'
+import { shortDigest } from './skill-package-digest'
+
+describe('shortDigest', () => {
+ // Why: a full 64-char digest on one nowrap line is wider than any dialog, and
+ // as a grid item it drags the whole dialog into horizontal scrolling.
+ it('keeps both ends so a digest stays comparable at a glance', () => {
+ expect(shortDigest(`${'a'.repeat(58)}bcdef1`)).toBe('aaaaaaaa…bcdef1')
+ expect(shortDigest('a'.repeat(64)).length).toBeLessThan(20)
+ })
+
+ it('leaves an already short value alone', () => {
+ expect(shortDigest('abc123')).toBe('abc123')
+ })
+})
diff --git a/src/renderer/src/components/skills/skill-package-digest.ts b/src/renderer/src/components/skills/skill-package-digest.ts
new file mode 100644
index 00000000000..b2a7dae19da
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-package-digest.ts
@@ -0,0 +1,8 @@
+/**
+ * Digests are shown so a reader can eyeball that two packages match; the full
+ * 64 characters never fit a dialog, and rendering them on one nowrap line sets
+ * the dialog's minimum content width and makes it scroll sideways.
+ */
+export function shortDigest(digest: string): string {
+ return digest.length > 16 ? `${digest.slice(0, 8)}…${digest.slice(-6)}` : digest
+}
diff --git a/src/renderer/src/components/skills/skill-share-preview-summary.test.ts b/src/renderer/src/components/skills/skill-share-preview-summary.test.ts
new file mode 100644
index 00000000000..92e9829d734
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-share-preview-summary.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest'
+import type { SkillSharePreview } from '../../../../shared/skill-sharing-contract'
+import { sensitiveShareFiles, summarizeShareRisk } from './skill-share-preview-summary'
+
+function preview(overrides: Partial = {}): SkillSharePreview {
+ return {
+ preparationId: 'prep',
+ packageId: 'pkg',
+ versionId: 'ver',
+ name: 'skill',
+ description: 'A skill',
+ packageDigest: 'a'.repeat(64),
+ archiveSha256: 'b'.repeat(64),
+ fileCount: 2,
+ totalBytes: 2048,
+ compressedBytes: 1024,
+ scriptPaths: [],
+ executablePaths: [],
+ expiresAt: '2026-08-11T01:00:00.000Z',
+ ...overrides
+ }
+}
+
+describe('summarizeShareRisk', () => {
+ it('reports the absence once instead of two zero counts', () => {
+ expect(summarizeShareRisk(preview())).toEqual({
+ risky: false,
+ label: 'No scripts or executables'
+ })
+ })
+
+ it('counts only the kinds that are present, with matching plurals', () => {
+ expect(summarizeShareRisk(preview({ scriptPaths: ['scripts/a.sh'] })).label).toBe('1 script')
+ expect(
+ summarizeShareRisk(preview({ scriptPaths: ['scripts/a.sh', 'scripts/b.sh'] })).label
+ ).toBe('2 scripts')
+ expect(
+ summarizeShareRisk(preview({ scriptPaths: ['scripts/a.sh'], executablePaths: ['bin/x'] }))
+ .label
+ ).toBe('1 script, 1 executable')
+ })
+})
+
+describe('sensitiveShareFiles', () => {
+ // Why: an executable script appears in both path sets; listing it twice would
+ // overstate how much of the package can run.
+ it('lists a file that is both script and executable once', () => {
+ const files = sensitiveShareFiles(
+ preview({ scriptPaths: ['scripts/setup.sh'], executablePaths: ['scripts/setup.sh'] })
+ )
+ expect(files).toEqual([{ path: 'scripts/setup.sh', script: true, executable: true }])
+ })
+
+ it('sorts paths so the same package always reviews in the same order', () => {
+ const files = sensitiveShareFiles(
+ preview({ scriptPaths: ['scripts/b.sh', 'scripts/a.sh'], executablePaths: ['bin/tool'] })
+ )
+ expect(files.map((file) => file.path)).toEqual(['bin/tool', 'scripts/a.sh', 'scripts/b.sh'])
+ })
+})
diff --git a/src/renderer/src/components/skills/skill-share-preview-summary.ts b/src/renderer/src/components/skills/skill-share-preview-summary.ts
new file mode 100644
index 00000000000..3251773f6af
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-share-preview-summary.ts
@@ -0,0 +1,66 @@
+import type { SkillSharePreview } from '../../../../shared/skill-sharing-contract'
+import { translate } from '@/i18n/i18n'
+
+export function byteLabel(bytes: number): string {
+ if (bytes < 1024) {
+ return `${bytes} B`
+ }
+ if (bytes < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KB`
+ }
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+function scriptCountLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.share.scriptOne', '{{count}} script', { count })
+ : translate('auto.components.skills.share.scriptOther', '{{count}} scripts', { count })
+}
+
+function executableCountLabel(count: number): string {
+ return count === 1
+ ? translate('auto.components.skills.share.executableOne', '{{count}} executable', { count })
+ : translate('auto.components.skills.share.executableOther', '{{count}} executables', { count })
+}
+
+export type SkillShareRiskSummary = { risky: boolean; label: string }
+
+/** Scripts and executables are the only part of a package that can act on the
+ * recipient's machine, so the summary names them instead of showing two zeros. */
+export function summarizeExecutableContent(
+ scriptCount: number,
+ executableCount: number
+): SkillShareRiskSummary {
+ const parts: string[] = []
+ if (scriptCount > 0) {
+ parts.push(scriptCountLabel(scriptCount))
+ }
+ if (executableCount > 0) {
+ parts.push(executableCountLabel(executableCount))
+ }
+ return parts.length === 0
+ ? {
+ risky: false,
+ label: translate(
+ 'auto.components.skills.share.noExecutableContent',
+ 'No scripts or executables'
+ )
+ }
+ : { risky: true, label: parts.join(', ') }
+}
+
+export function summarizeShareRisk(preview: SkillSharePreview): SkillShareRiskSummary {
+ return summarizeExecutableContent(preview.scriptPaths.length, preview.executablePaths.length)
+}
+
+export type SkillShareSensitiveFile = { path: string; script: boolean; executable: boolean }
+
+/** The reviewable subset the preview actually carries: a package's full file
+ * list is not part of `SkillSharePreview`, only these two path sets are. */
+export function sensitiveShareFiles(preview: SkillSharePreview): SkillShareSensitiveFile[] {
+ const scripts = new Set(preview.scriptPaths)
+ const executables = new Set(preview.executablePaths)
+ return [...new Set([...preview.scriptPaths, ...preview.executablePaths])]
+ .sort((left, right) => left.localeCompare(right))
+ .map((path) => ({ path, script: scripts.has(path), executable: executables.has(path) }))
+}
diff --git a/src/renderer/src/components/skills/skill-share-selection.test.ts b/src/renderer/src/components/skills/skill-share-selection.test.ts
new file mode 100644
index 00000000000..a0e17e4b6ee
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-share-selection.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import type { DiscoveredSkill } from '../../../../shared/skills'
+import { addShareableSkillResults, eligibleShareSkillCount } from './skill-share-selection'
+
+function skill(name: string, overrides: Partial = {}): DiscoveredSkill {
+ return {
+ id: `${overrides.sourceKind ?? 'home'}:${name}`,
+ name,
+ description: null,
+ providers: ['agent-skills'],
+ sourceKind: 'home',
+ sourceLabel: 'Agent skills home',
+ rootPath: '/home/dev/.agents/skills',
+ directoryPath: `/home/dev/.agents/skills/${name}`,
+ skillFilePath: `/home/dev/.agents/skills/${name}/SKILL.md`,
+ installed: true,
+ fileCount: 1,
+ updatedAt: null,
+ ...overrides
+ }
+}
+
+describe('eligibleShareSkillCount', () => {
+ // Why: the count labels the "Select all" button, so it has to predict what
+ // that click actually produces — duplicates collapse, ineligible rows drop.
+ it('matches what selecting all results would hold', () => {
+ const results = [
+ skill('alpha'),
+ skill('alpha', { sourceKind: 'repo' }),
+ skill('beta', { sourceKind: 'plugin' }),
+ skill('gamma', { installed: false })
+ ]
+ expect(eligibleShareSkillCount(results, true)).toBe(1)
+ expect(addShareableSkillResults(new Set(), results, results, true).size).toBe(1)
+ })
+
+ it('counts nothing when the skills live on another machine', () => {
+ expect(eligibleShareSkillCount([skill('alpha'), skill('beta')], false)).toBe(0)
+ })
+})
diff --git a/src/renderer/src/components/skills/SkillShareSelectionControls.tsx b/src/renderer/src/components/skills/skill-share-selection.ts
similarity index 64%
rename from src/renderer/src/components/skills/SkillShareSelectionControls.tsx
rename to src/renderer/src/components/skills/skill-share-selection.ts
index f49f31dd030..861bd4f3f58 100644
--- a/src/renderer/src/components/skills/SkillShareSelectionControls.tsx
+++ b/src/renderer/src/components/skills/skill-share-selection.ts
@@ -1,5 +1,3 @@
-import { Share2 } from 'lucide-react'
-import { Button } from '@/components/ui/button'
import type { DiscoveredSkill } from '../../../../shared/skills'
import { translate } from '@/i18n/i18n'
@@ -51,6 +49,21 @@ export function selectedShareSkillNameKeys(
)
}
+/** How many rows "Select all" would end up holding — duplicate names collapse to
+ * one, so the count has to dedup the same way the selection itself does. */
+export function eligibleShareSkillCount(
+ results: readonly DiscoveredSkill[],
+ local: boolean
+): number {
+ const names = new Set()
+ for (const skill of results) {
+ if (isSkillShareEligible(skill, local)) {
+ names.add(shareSkillNameKey(skill))
+ }
+ }
+ return names.size
+}
+
export function addShareableSkillResults(
current: ReadonlySet,
skills: readonly DiscoveredSkill[],
@@ -101,55 +114,3 @@ export function updatedSkillSelection(
}
return next
}
-
-export function SkillShareSelectionAction({
- selecting,
- selectedCount,
- onClick
-}: {
- selecting: boolean
- selectedCount: number
- onClick: () => void
-}): React.JSX.Element {
- const label = selecting
- ? selectedCount > 0
- ? translate(
- 'auto.components.skills.SkillShareSelectionControls.01c5a15e05',
- 'Share {{value0}} skills',
- { value0: selectedCount }
- )
- : translate('auto.components.skills.SkillShareSelectionControls.01c5a15e01', 'Cancel sharing')
- : translate('auto.components.skills.SkillShareSelectionControls.01c5a15e02', 'Share skills')
- return (
-
-
- {label}
-
- )
-}
-
-export function SkillShareSelectionStatus({
- selectedCount,
- onSelectAll
-}: {
- selectedCount: number
- onSelectAll: () => void
-}): React.JSX.Element {
- return (
-
-
- {translate(
- 'auto.components.skills.SkillShareSelectionControls.01c5a15e03',
- '{{value0}} selected',
- { value0: selectedCount }
- )}
-
-
- {translate(
- 'auto.components.skills.SkillShareSelectionControls.01c5a15e04',
- 'Select all results'
- )}
-
-
- )
-}
diff --git a/src/renderer/src/components/skills/skill-source-inventory.test.ts b/src/renderer/src/components/skills/skill-source-inventory.test.ts
new file mode 100644
index 00000000000..0f45df417de
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-source-inventory.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+import type {
+ DiscoveredSkill,
+ SkillDiscoveryResult,
+ SkillDiscoverySource
+} from '../../../../shared/skills'
+import { scannedSkillSourceCount, summarizeSkillSources } from './skill-source-inventory'
+
+function source(overrides: Partial = {}): SkillDiscoverySource {
+ return {
+ id: 'home',
+ label: 'Agent skills home',
+ path: '/home/dev/.agents/skills',
+ sourceKind: 'home',
+ providers: ['agent-skills'],
+ owner: null,
+ exists: true,
+ ...overrides
+ }
+}
+
+function skill(overrides: Partial = {}): DiscoveredSkill {
+ return {
+ id: 'skill',
+ name: 'skill',
+ description: null,
+ providers: ['agent-skills'],
+ sourceKind: 'home',
+ sourceLabel: 'Agent skills home',
+ rootPath: '/home/dev/.agents/skills',
+ directoryPath: '/home/dev/.agents/skills/skill',
+ skillFilePath: '/home/dev/.agents/skills/skill/SKILL.md',
+ installed: true,
+ fileCount: 1,
+ updatedAt: null,
+ ...overrides
+ }
+}
+
+function result(overrides: Partial = {}): SkillDiscoveryResult {
+ return { skills: [], sources: [], scannedAt: 1, ...overrides }
+}
+
+describe('summarizeSkillSources', () => {
+ it('counts a symlinked skill under every root that reached it', () => {
+ const shared = source({ id: 'repo', path: '/repo/.agents/skills', sourceKind: 'repo' })
+ const entries = summarizeSkillSources(
+ result({
+ sources: [source(), shared],
+ skills: [skill({ rootPaths: ['/home/dev/.agents/skills', '/repo/.agents/skills'] })]
+ })
+ )
+ expect(entries.map((entry) => entry.skillCount)).toEqual([1, 1])
+ })
+
+ it('reports why a root produced nothing instead of showing an empty count', () => {
+ const entries = summarizeSkillSources(
+ result({
+ sources: [
+ source({ id: 'missing', exists: false, skippedReason: 'missing' }),
+ source({ id: 'remote', exists: false, skippedReason: 'remote-repo' }),
+ source({ id: 'unknown', exists: false })
+ ]
+ })
+ )
+ expect(entries.map((entry) => entry.status)).toEqual(['missing', 'remote-repo', 'unavailable'])
+ expect(scannedSkillSourceCount(entries)).toBe(0)
+ })
+
+ it('counts only roots that were actually scanned', () => {
+ const entries = summarizeSkillSources(
+ result({ sources: [source(), source({ id: 'gone', exists: false })] })
+ )
+ expect(scannedSkillSourceCount(entries)).toBe(1)
+ })
+})
diff --git a/src/renderer/src/components/skills/skill-source-inventory.ts b/src/renderer/src/components/skills/skill-source-inventory.ts
new file mode 100644
index 00000000000..df702333c38
--- /dev/null
+++ b/src/renderer/src/components/skills/skill-source-inventory.ts
@@ -0,0 +1,46 @@
+import type {
+ DiscoveredSkill,
+ SkillDiscoveryResult,
+ SkillDiscoverySource
+} from '../../../../shared/skills'
+
+export type SkillSourceStatus = 'scanned' | 'missing' | 'remote-repo' | 'unavailable'
+
+export type SkillSourceInventoryEntry = {
+ source: SkillDiscoverySource
+ skillCount: number
+ status: SkillSourceStatus
+}
+
+function ownsSkill(source: SkillDiscoverySource, skill: DiscoveredSkill): boolean {
+ // Why: a symlinked skill is deduped to one row but keeps every root that
+ // reached it, so counting only `rootPath` would zero out the co-owning roots.
+ return skill.rootPath === source.path || (skill.rootPaths?.includes(source.path) ?? false)
+}
+
+function sourceStatus(source: SkillDiscoverySource): SkillSourceStatus {
+ if (source.exists) {
+ return 'scanned'
+ }
+ if (source.skippedReason === 'missing' || source.skippedReason === 'remote-repo') {
+ return source.skippedReason
+ }
+ return 'unavailable'
+}
+
+export function summarizeSkillSources(
+ result: SkillDiscoveryResult | null
+): SkillSourceInventoryEntry[] {
+ if (!result) {
+ return []
+ }
+ return result.sources.map((source) => ({
+ source,
+ skillCount: result.skills.filter((skill) => ownsSkill(source, skill)).length,
+ status: sourceStatus(source)
+ }))
+}
+
+export function scannedSkillSourceCount(entries: readonly SkillSourceInventoryEntry[]): number {
+ return entries.filter((entry) => entry.status === 'scanned').length
+}
diff --git a/src/renderer/src/components/skills/skills-filter.test.ts b/src/renderer/src/components/skills/skills-filter.test.ts
index a320d37ca91..d8207e726b7 100644
--- a/src/renderer/src/components/skills/skills-filter.test.ts
+++ b/src/renderer/src/components/skills/skills-filter.test.ts
@@ -38,14 +38,22 @@ describe('skills filtering', () => {
})
]
+ // Why: the owning agent comes from the root a skill was found in, not from
+ // its provider list, which flattens ten agents into "agent-skills".
+ const agentByRootPath = new Map([
+ ['/home/dev/.codex/skills', 'codex'],
+ ['/repo/.claude/skills', 'claude']
+ ])
expect(
- filterSkills(skills, { query: 'docs', provider: 'claude', sourceKind: 'repo' }).map(
- (item) => item.name
- )
+ filterSkills(skills, { query: 'docs', agent: 'claude', sourceKind: 'repo' }, agentByRootPath)
+ ).toEqual([])
+ expect(
+ filterSkills(
+ skills,
+ { query: 'docs', agent: 'all', sourceKind: 'repo' },
+ agentByRootPath
+ ).map((item) => item.name)
).toEqual(['Docs Writer'])
- expect(filterSkills(skills, { query: 'docs', provider: 'codex', sourceKind: 'all' })).toEqual(
- []
- )
})
it('rejects oversized pasted queries before reading skill metadata', () => {
@@ -68,7 +76,7 @@ describe('skills filtering', () => {
expect(
filterSkills(throwingSkills, {
query: oversizedQuery,
- provider: 'all',
+ agent: 'all',
sourceKind: 'all'
})
).toEqual([])
diff --git a/src/renderer/src/components/skills/skills-filter.ts b/src/renderer/src/components/skills/skills-filter.ts
index 822f41b77ca..9d71deeeb6d 100644
--- a/src/renderer/src/components/skills/skills-filter.ts
+++ b/src/renderer/src/components/skills/skills-filter.ts
@@ -1,10 +1,12 @@
-import type { DiscoveredSkill, SkillProvider, SkillSourceKind } from '../../../../shared/skills'
+import type { DiscoveredSkill, SkillSourceKind } from '../../../../shared/skills'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
+import { skillMatchesAgent } from './skill-agent-filter'
export type SkillsFilterState = {
query: string
sourceKind: SkillSourceKind | 'all'
- provider: SkillProvider | 'all'
+ /** Owning agent id, or 'all'. Resolved from the root a skill was found in. */
+ agent: string
}
export const SKILLS_FILTER_QUERY_MAX_BYTES = 2 * 1024
@@ -22,7 +24,8 @@ function normalize(value: string): string {
export function filterSkills(
skills: readonly DiscoveredSkill[],
- filters: SkillsFilterState
+ filters: SkillsFilterState,
+ agentByRootPath: ReadonlyMap = new Map()
): DiscoveredSkill[] {
if (isSkillsFilterQueryTooLarge(filters.query)) {
return []
@@ -32,7 +35,7 @@ export function filterSkills(
if (filters.sourceKind !== 'all' && skill.sourceKind !== filters.sourceKind) {
return false
}
- if (filters.provider !== 'all' && !skill.providers.includes(filters.provider)) {
+ if (!skillMatchesAgent(skill, filters.agent, agentByRootPath)) {
return false
}
if (!query) {
diff --git a/src/renderer/src/components/skills/skills-page-column.ts b/src/renderer/src/components/skills/skills-page-column.ts
new file mode 100644
index 00000000000..7920700e8c1
--- /dev/null
+++ b/src/renderer/src/components/skills/skills-page-column.ts
@@ -0,0 +1,4 @@
+/** Every band on the Skills page aligns to this column so the header, filters,
+ * and rows share one left edge. Full-bleed like Artifacts: the page owns the
+ * content area, so capping the width would waste it on wide windows. */
+export const SKILLS_PAGE_COLUMN = 'w-full px-5 md:px-8'
diff --git a/src/renderer/src/components/skills/skills-page-states.tsx b/src/renderer/src/components/skills/skills-page-states.tsx
new file mode 100644
index 00000000000..1db52379e8f
--- /dev/null
+++ b/src/renderer/src/components/skills/skills-page-states.tsx
@@ -0,0 +1,122 @@
+import { BookOpen, Download, RefreshCw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { SKILLS_PAGE_COLUMN } from './skills-page-column'
+
+const SKELETON_ROWS = [0, 1, 2, 3, 4, 5, 6, 7]
+
+export function SkillsListSkeleton(): React.JSX.Element {
+ return (
+
+
+ {translate('auto.components.skills.SkillsPage.cd7893fbc1', 'Scanning skills')}
+
+ {SKELETON_ROWS.map((row) => (
+
+ ))}
+
+ )
+}
+
+export function SkillsNoMatchesState({
+ onClearFilters
+}: {
+ onClearFilters: () => void
+}): React.JSX.Element {
+ return (
+
+
+ {translate('auto.components.skills.SkillsPage.6a62a0168c', 'No matches')}
+
+
+ {translate(
+ 'auto.components.skills.SkillsPage.08a321a984',
+ 'No skill matches the current search and filters.'
+ )}
+
+
+ {translate('auto.components.skills.SkillsPage.clearFilters', 'Clear filters')}
+
+
+ )
+}
+
+export function SkillsEmptyState({
+ onRefresh,
+ onInstallFromLink
+}: {
+ onRefresh: () => void
+ onInstallFromLink: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+
+ {translate('auto.components.skills.SkillsPage.4acd6d68ec', 'No skills found')}
+
+
+ {translate(
+ 'auto.components.skills.SkillsPage.emptyCopy',
+ 'The scanned skill folders are empty. Install a shared bundle, or refresh after adding a skill.'
+ )}
+
+
+
+
+
+ {translate('auto.components.skills.SkillsPage.aee7b99cc6', 'Install from link')}
+
+
+
+ {translate('auto.components.skills.SkillsPage.cb142070b4', 'Refresh')}
+
+
+
+ )
+}
+
+export function SkillsScanErrorBand({
+ message,
+ disabled,
+ onRetry
+}: {
+ message: string
+ disabled: boolean
+ onRetry: () => void
+}): React.JSX.Element {
+ return (
+
+
+
+ {message}
+
+
+ {translate('auto.components.skills.SkillsPage.retry', 'Retry')}
+
+
+
+ )
+}
+
+/** One page-level sentence instead of the same reason repeated on every row. */
+export function SkillsRemoteShareNotice({ hostLabel }: { hostLabel: string }): React.JSX.Element {
+ return (
+
+ {translate(
+ 'auto.components.skills.SkillsPage.remoteShareNotice',
+ 'These skills live on {{host}}. Open Skills on that machine to share them.',
+ { host: hostLabel }
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/skills/skills-page-view.ts b/src/renderer/src/components/skills/skills-page-view.ts
new file mode 100644
index 00000000000..95b1b356679
--- /dev/null
+++ b/src/renderer/src/components/skills/skills-page-view.ts
@@ -0,0 +1,3 @@
+/** The page lists either the skills on this machine or the links you published
+ * from them. Both are inventories, so they share the page's scroll and search. */
+export type SkillsPageView = 'skills' | 'shared'
diff --git a/src/renderer/src/components/skills/skills-subtitle-action.ts b/src/renderer/src/components/skills/skills-subtitle-action.ts
new file mode 100644
index 00000000000..81e95bfab9b
--- /dev/null
+++ b/src/renderer/src/components/skills/skills-subtitle-action.ts
@@ -0,0 +1,4 @@
+/** Buttons that sit inline in the header's subtitle line: they must read as
+ * part of the sentence and add no height, so no padding and no underline. */
+export const SKILLS_SUBTITLE_ACTION_CLASS =
+ 'h-auto gap-0.5 p-0 text-xs font-normal text-muted-foreground no-underline hover:text-foreground hover:no-underline disabled:opacity-40 has-[>svg]:px-0'
diff --git a/src/renderer/src/components/skills/use-owned-skill-shares.ts b/src/renderer/src/components/skills/use-owned-skill-shares.ts
new file mode 100644
index 00000000000..0b60ec996fd
--- /dev/null
+++ b/src/renderer/src/components/skills/use-owned-skill-shares.ts
@@ -0,0 +1,97 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+import type { SkillCloudOwnedShare } from '../../../../shared/skill-cloud-contract'
+import { translate } from '@/i18n/i18n'
+
+function inventoryError(status: string): string {
+ return status === 'reconnect-required'
+ ? translate(
+ 'auto.components.settings.shareSkills.linksReconnect',
+ 'Sign in again to manage shared links.'
+ )
+ : translate(
+ 'auto.components.settings.shareSkills.linksUnavailable',
+ 'Shared links are unavailable right now.'
+ )
+}
+
+export type OwnedSkillShares = {
+ shares: SkillCloudOwnedShare[]
+ loading: boolean
+ error: string | null
+ busyShareId: string | null
+ refresh: () => void
+ revoke: (share: SkillCloudOwnedShare) => Promise
+}
+
+export function useOwnedSkillShares(): OwnedSkillShares {
+ const [shares, setShares] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [busyShareId, setBusyShareId] = useState(null)
+ const [error, setError] = useState(null)
+ const generation = useRef(0)
+
+ const load = useCallback(async (): Promise => {
+ const current = ++generation.current
+ setLoading(true)
+ setError(null)
+ try {
+ const operation = await window.api.skills.listOwnedShares()
+ if (generation.current !== current) {
+ return
+ }
+ if (operation.status !== 'ok') {
+ setError(inventoryError(operation.status))
+ return
+ }
+ setShares(operation.value)
+ } catch {
+ if (generation.current === current) {
+ setError(inventoryError('unavailable'))
+ }
+ } finally {
+ if (generation.current === current) {
+ setLoading(false)
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ void load()
+ return () => {
+ generation.current += 1
+ }
+ }, [load])
+
+ const revoke = useCallback(async (share: SkillCloudOwnedShare): Promise => {
+ setBusyShareId(share.id)
+ setError(null)
+ try {
+ const operation = await window.api.skills.revokeShare(share.id)
+ if (operation.status !== 'ok') {
+ setError(inventoryError(operation.status))
+ return
+ }
+ setShares((current) => current.filter((candidate) => candidate.id !== share.id))
+ toast.success(translate('auto.components.settings.shareSkills.linkRevoked', 'Link revoked'))
+ } catch {
+ setError(
+ translate(
+ 'auto.components.settings.shareSkills.revokeFailed',
+ 'Orca could not revoke this link.'
+ )
+ )
+ } finally {
+ setBusyShareId(null)
+ }
+ }, [])
+
+ return {
+ shares,
+ loading,
+ error,
+ busyShareId,
+ refresh: () => void load(),
+ revoke
+ }
+}
diff --git a/src/renderer/src/components/skills/use-skill-discovery-host-label.ts b/src/renderer/src/components/skills/use-skill-discovery-host-label.ts
new file mode 100644
index 00000000000..5386ac92214
--- /dev/null
+++ b/src/renderer/src/components/skills/use-skill-discovery-host-label.ts
@@ -0,0 +1,24 @@
+import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
+import { useAppStore } from '@/store'
+import { translate } from '@/i18n/i18n'
+
+/**
+ * Names the machine whose skill folders were scanned, so a remote or WSL user
+ * can tell at a glance why sharing is unavailable without reading a per-row
+ * sentence. Returns null while the owning runtime is still unknown.
+ */
+export function useSkillDiscoveryHostLabel(target: RuntimeClientTarget | null): string | null {
+ const environmentName = useAppStore((state) =>
+ target?.kind === 'environment'
+ ? (state.runtimeEnvironments.find((environment) => environment.id === target.environmentId)
+ ?.name ?? null)
+ : null
+ )
+ if (!target) {
+ return null
+ }
+ if (target.kind === 'local') {
+ return translate('auto.components.skills.host.local', 'This machine')
+ }
+ return environmentName ?? translate('auto.components.skills.host.remote', 'Connected runtime')
+}
diff --git a/src/renderer/src/components/skills/use-skill-install-detected-agents.ts b/src/renderer/src/components/skills/use-skill-install-detected-agents.ts
new file mode 100644
index 00000000000..1c71f23744b
--- /dev/null
+++ b/src/renderer/src/components/skills/use-skill-install-detected-agents.ts
@@ -0,0 +1,46 @@
+import { useEffect, useRef, useState } from 'react'
+
+/**
+ * Agents present on the machine the install targets, or null while that cannot
+ * be answered — a paired runtime environment is probed by its own host, not
+ * from here, so the picker must not claim its agents are missing.
+ */
+export function useSkillInstallDetectedAgents(target: {
+ environmentId: string
+ wslDistro?: string | null
+}): readonly string[] | null {
+ const [detected, setDetected] = useState(null)
+ const generationRef = useRef(0)
+ const { environmentId, wslDistro } = target
+
+ useEffect(() => {
+ const generation = ++generationRef.current
+ const isCurrent = (): boolean => generation === generationRef.current
+ setDetected(null)
+ const probe = async (): Promise => {
+ if (environmentId.startsWith('ssh:')) {
+ return window.api.preflight.detectRemoteAgents({
+ connectionId: environmentId.slice('ssh:'.length)
+ })
+ }
+ if (environmentId !== 'local') {
+ return null
+ }
+ return window.api.preflight.detectAgents(wslDistro ? { wslDistro } : undefined)
+ }
+ void probe()
+ .then((agents) => {
+ if (isCurrent()) {
+ setDetected(agents)
+ }
+ })
+ .catch((cause) => {
+ console.warn('[skills] agent detection failed:', cause)
+ })
+ return () => {
+ generationRef.current += 1
+ }
+ }, [environmentId, wslDistro])
+
+ return detected
+}
diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts
index 56e861abcde..002205665ed 100644
--- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts
+++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts
@@ -33,7 +33,7 @@ import {
SquareTerminal,
TextCursorInput,
Files,
- Share2,
+ BookOpen,
UserCog,
Wrench
} from 'lucide-react'
@@ -355,9 +355,11 @@ export function buildSettingsNavigationMetadata({
title: translate('auto.hooks.useSettingsNavigationMetadata.shareSkillsTitle', 'Share Skills'),
description: translate(
'auto.hooks.useSettingsNavigationMetadata.shareSkillsDescription',
- 'Share one or many skills through unlisted links and manage access by revoking links.'
+ 'Share your skills with an unlisted link. Anyone who has it can install them.'
),
- icon: Share2,
+ // Why: the sidebar entry and the page header both use BookOpen for
+ // skills, so the settings row that opens them matches.
+ icon: BookOpen,
searchEntries: getShareSkillsSettingsSearchEntries(),
group: 'workflows',
badge: translate('auto.hooks.useSettingsNavigationMetadata.40d80bad8a', 'Beta')
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 53d35c67417..cb39ccd071c 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -903,7 +903,7 @@
"automationsTitle": "Automations",
"automationsDescription": "Schedule agent work and choose whether Automations appears in the sidebar.",
"shareSkillsTitle": "Share Skills",
- "shareSkillsDescription": "Share one or many skills through unlisted links and manage access by revoking links."
+ "shareSkillsDescription": "Share your skills with an unlisted link. Anyone who has it can install them."
},
"useAppMenuPaste": {
"pasteTooLarge": "Paste is too large."
@@ -4026,7 +4026,20 @@
"35b9a724a0": "Available",
"0c74e7ff34": "Installed",
"c13b82793c": "Manage installs",
- "aee7b99cc6": "Install from link"
+ "aee7b99cc6": "Install from link",
+ "filterProvider": "Filter by agent",
+ "filterSource": "Filter by source",
+ "allSources": "All",
+ "clearFilters": "Clear filters",
+ "closeSkills": "Close skills",
+ "closeTooltip": "Close · Esc",
+ "moreActions": "More actions",
+ "sharedLinks": "Shared links",
+ "emptyCopy": "The scanned skill folders are empty. Install a shared bundle, or refresh after adding a skill.",
+ "retry": "Retry",
+ "remoteShareNotice": "These skills live on {{host}}. Open Skills on that machine to share them.",
+ "viewSwitch": "Show",
+ "searchLinks": "Search links"
},
"SkillFreshnessNudge": {
"titleOne": "An installed Orca skill is out of date",
@@ -4183,7 +4196,8 @@
"d198ec91e5": "Close",
"4a00d133c5": "Orca verifies access and package identity before changing the selected machine.",
"fcbec627cc": "Install shared skill",
- "01c5a14e01": "Install shared skills"
+ "01c5a14e01": "Install shared skills",
+ "opening": "Opening this link…"
},
"SkillInstallManagementDialog": {
"8095927ff3": "Close",
@@ -4342,6 +4356,163 @@
},
"skill-install-progress-state": {
"currentSkill": "Installing {{value0}} of {{value1}}: {{value2}}…"
+ },
+ "SkillRow": {
+ "updatedUnknown": "No date",
+ "pathCopied": "Path copied",
+ "copyPath": "Copy path",
+ "notShareable": "Not shareable",
+ "detailPath": "Path",
+ "detailSource": "Source",
+ "detailContents": "Contents",
+ "skillActions": "Actions for {{value0}}",
+ "viewDetails": "View details"
+ },
+ "SkillSelectionBar": {
+ "selectAll": "Select all {{count}} eligible",
+ "clear": "Clear"
+ },
+ "SkillsList": {
+ "listLabel": "Skills"
+ },
+ "sourceStatus": {
+ "missing": "Folder not found",
+ "remoteRepo": "Remote repo — not scanned",
+ "unavailable": "Not scanned"
+ },
+ "sources": {
+ "heading": "Skill folders"
+ },
+ "sourceKind": {
+ "home": "Home",
+ "workspace": "Workspace",
+ "bundled": "Bundled",
+ "plugin": "Plugin"
+ },
+ "count": {
+ "skillOne": "{{count}} skill",
+ "skillOther": "{{count}} skills",
+ "sourceOne": "{{count}} source",
+ "sourceOther": "{{count}} sources",
+ "fileOne": "{{count}} file",
+ "fileOther": "{{count}} files",
+ "resultOne": "{{count}} result",
+ "resultOther": "{{count}} results",
+ "selected": "{{count}} selected",
+ "shareOne": "Share {{count}} skill",
+ "shareOther": "Share {{count}} skills",
+ "installOne": "Install {{count}} skill",
+ "installOther": "Install {{count}} skills",
+ "retryOne": "Retry {{count}} skill",
+ "retryOther": "Retry {{count}} skills",
+ "linkOne": "{{count}} link",
+ "linkOther": "{{count}} links"
+ },
+ "host": {
+ "local": "This machine",
+ "remote": "Connected runtime"
+ },
+ "share": {
+ "fileExecutable": "executable",
+ "fileScript": "script",
+ "reviewFiles": "Review files that can run",
+ "reviewSkills": "Review included skills",
+ "releaseNotesVersion": "Release notes",
+ "releaseNotesOptional": "Add release notes (optional)",
+ "releaseNotesFirst": "Describe this release",
+ "readyDescription": "Copy the link to share it.",
+ "newVersionDescription": "Publishes an immutable version to the existing package.",
+ "description": "Publishes an immutable version behind an unlisted link.",
+ "accessSummary": "Anyone with the link can install this. It is not listed anywhere, and revoking it blocks new installs — not copies already installed. Publishing as {{value0}}.",
+ "manageLinks": "Manage or revoke this link in Settings",
+ "scriptOne": "{{count}} script",
+ "scriptOther": "{{count}} scripts",
+ "executableOne": "{{count}} executable",
+ "executableOther": "{{count}} executables",
+ "noExecutableContent": "No scripts or executables",
+ "newVersionDescriptionPlain": "Adds a new version to the existing link.",
+ "accessSummaryShort": "Unlisted link — anyone with it can install this. Publishing as {{value0}}.",
+ "accessSummaryPlain": "Unlisted link — anyone with it can install this."
+ },
+ "description": {
+ "showLess": "Show less",
+ "showMore": "Show more"
+ },
+ "install": {
+ "agentsLabel": "Agents",
+ "agentsCanonical": "Always installed for {{value0}}, which read {{value1}}.",
+ "agentsNoneChosen": "No extra agents",
+ "agentsSummary": "Also: {{value0}}",
+ "agentNotInstalled": "Not installed",
+ "bundleDestinationSummary": "{{value0}} new · {{value1}} already installed · {{value2}} need a decision",
+ "trustNote": "Skills are instructions and code from their author. Install what you trust.",
+ "agentsNone": "No agents selected",
+ "agentsSelected": "Installing for: {{value0}}",
+ "agentAlways": "Always",
+ "agentsCanonicalNote": "{{value0}} read {{value1}}, which every install writes.",
+ "linkHint": "Opening a link never installs anything — you review it first.",
+ "rowNeedsDecision": "Needs a decision",
+ "rowInstalled": "Already installed",
+ "rowUpdate": "Update",
+ "rowNew": "New",
+ "chooseSkills": "Choose what to install from this link."
+ },
+ "filter": {
+ "allAgents": "All agents",
+ "sharedAgent": "Shared (.agents)"
+ },
+ "SkillsSelectionHeader": {
+ "exit": "Exit selection",
+ "exitTooltip": "Exit selection · Esc",
+ "title": "Select skills to share",
+ "selectAll": "Select all {{count}} eligible",
+ "clear": "Clear"
+ },
+ "SkillDetailDialog": {
+ "agents": "Agents",
+ "updated": "Updated",
+ "copy": "Copy"
+ },
+ "SkillSharedLinkRow": {
+ "contentsUnavailable": "Could not load what this link contains.",
+ "loading": "Loading contents…",
+ "deleteFailed": "Orca could not delete this from the Cloud.",
+ "deleted": "Deleted from the Cloud",
+ "confirmDelete": "Confirm delete",
+ "moreActions": "More actions for {{name}}",
+ "deletePackage": "Delete from the Cloud"
+ },
+ "SkillSharedLinksView": {
+ "loading": "Loading shared links…",
+ "noMatches": "No links match this search."
+ },
+ "SkillsSharedLinksHeader": {
+ "back": "Back to skills",
+ "backTooltip": "Back to skills · Esc",
+ "unlisted": "Unlisted — only people with a link can open it."
+ },
+ "managedInstall": {
+ "versionLabel": "Version",
+ "editedWarning": "Your edits are kept unless you choose to discard them.",
+ "finishInstall": "Finish installing",
+ "useVersion": "Use this version",
+ "reinstall": "Reinstall",
+ "cancel": "Cancel",
+ "sendToMachine": "Send to another machine",
+ "confirmRemove": "Confirm remove",
+ "remove": "Remove",
+ "discardEditsRemove": "Discard my edits and remove",
+ "discardEdits": "Discard my edits and reinstall",
+ "titleMore": "{{name}} +{{count}}",
+ "scopeWorkspace": "This workspace",
+ "scopeGlobal": "Everywhere",
+ "installedOn": "Installed {{date}}",
+ "stateEdited": "Edited after installing",
+ "stateMissing": "Files are missing",
+ "skillEdited": "Edited",
+ "skillMissing": "Missing",
+ "versionCurrent": "{{date}} (installed)",
+ "installElsewhere": "Install elsewhere…"
}
},
"sidebar": {
@@ -4714,7 +4885,8 @@
"0ccba862b8": "Open GitHub tasks",
"fee535205b": "Tasks",
"d599269755": "Hide from sidebar",
- "artifacts": "Artifacts"
+ "artifacts": "Artifacts",
+ "skills": "Skills"
},
"SidebarRepositoryFilterSection": {
"d3a9c4cea1": "Clear",
@@ -10784,7 +10956,9 @@
"artifactsTitle": "Artifact sharing",
"artifactsDescription": "Publish HTML and Markdown files, then manage every shared link from Orca.",
"relayTitle": "Orca Relay",
- "relayDescription": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi."
+ "relayDescription": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi.",
+ "skillsTitle": "Skill sharing",
+ "skillsDescription": "Share one skill or a whole set behind an unlisted link, and install them on any machine you use."
},
"automations": {
"showButton": "Show Automations Button",
@@ -10809,7 +10983,7 @@
},
"shareSkills": {
"title": "Share Skills",
- "description": "Share one or many skills through unlisted, revocable links.",
+ "description": "Share your skills with an unlisted link. Anyone who has it can install them.",
"selectTitle": "Select one or more skills",
"selectDescription": "Open Skills, choose Share skills, and select the skills to bundle behind one link.",
"reviewTitle": "Review and publish",
@@ -10830,7 +11004,7 @@
"howToDescription": "Publish one skill or a bundle such as a collection of 30 skills behind one link.",
"openSkills": "Open Skills",
"openSkillsDescription": "Publish a bundle, install from a link, or manage installed and shared skills.",
- "searchDescription": "Share one or more skills behind an unlisted, revocable link.",
+ "searchDescription": "Share your skills with an unlisted link. Anyone who has it can install them.",
"linksReconnect": "Sign in again to manage shared links.",
"linksUnavailable": "Shared links are unavailable right now.",
"linkCopied": "Share link copied",
@@ -10842,7 +11016,10 @@
"noActiveLinks": "No active links. Publish a skill bundle from Skills to create one.",
"copyLink": "Copy link",
"confirmUnshare": "Confirm unshare",
- "unshare": "Unshare"
+ "unshare": "Unshare",
+ "showButton": "Show Skills button",
+ "showButtonDescription": "Show the Skills shortcut in the sidebar.",
+ "manageInSkills": "Manage in Skills"
}
},
"right": {
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index cbf013c2cbb..470c709d077 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -765,6 +765,10 @@ export type UISlice = {
pendingSkillShareId: string | null
openSkillShare: (shareId: string) => void
clearPendingSkillShare: () => void
+ /** Set when another surface links straight to the page's shared-links view. */
+ pendingSkillsSharedView: boolean
+ openSkillsSharedLinks: () => void
+ clearPendingSkillsSharedView: () => void
openArtifactsPage: () => void
closeArtifactsPage: () => void
openMobilePage: () => void
@@ -1262,6 +1266,7 @@ export const createUISlice: StateCreator = (set, get)
previousViewBeforeSpace: 'terminal',
previousViewBeforeSkills: 'terminal',
pendingSkillShareId: null,
+ pendingSkillsSharedView: false,
previousViewBeforeMobile: 'terminal',
previousViewBeforeArtifacts: 'terminal',
setActiveView: (view) => set({ activeView: view }),
@@ -1519,6 +1524,14 @@ export const createUISlice: StateCreator = (set, get)
pendingSkillShareId: shareId
})),
clearPendingSkillShare: () => set({ pendingSkillShareId: null }),
+ openSkillsSharedLinks: () =>
+ set((state) => ({
+ activeView: 'skills',
+ previousViewBeforeSkills:
+ state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView,
+ pendingSkillsSharedView: true
+ })),
+ clearPendingSkillsSharedView: () => set({ pendingSkillsSharedView: false }),
openArtifactsPage: () =>
set((state) => ({
activeView: 'artifacts',
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 8da7cb5b9d1..dbb5d59aa08 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -281,6 +281,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
artifactsEnabled: true,
artifactSharingEnabled: false,
showArtifactsButton: false,
+ showSkillsButton: false,
showMobileButton: true,
showPinnedWorktreesInGroups: false,
ctrlTabOrderMode: 'mru',
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index dcf047736d0..815c390501b 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -4,6 +4,7 @@ import {
SKILL_INSTALL_CAPABILITY,
SKILL_INSTALL_CANCEL_CAPABILITY,
SKILL_INSTALL_PROGRESS_CAPABILITY,
+ SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
@@ -135,7 +136,8 @@ export const RUNTIME_CAPABILITIES = [
SKILL_INSTALL_PROGRESS_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
SKILL_UPLOAD_CAPABILITY,
- SKILL_MANAGEMENT_CAPABILITY
+ SKILL_MANAGEMENT_CAPABILITY,
+ SKILL_INSTALL_PROVIDERS_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
diff --git a/src/shared/skill-bundle-install-contract.ts b/src/shared/skill-bundle-install-contract.ts
index ba8c9118ea9..2269c3e5291 100644
--- a/src/shared/skill-bundle-install-contract.ts
+++ b/src/shared/skill-bundle-install-contract.ts
@@ -55,6 +55,8 @@ export const SkillBundleInstallRequestSchema = z
selectedSkillIds: z.array(ID_SCHEMA).min(1).max(512),
ingress: SkillBundleInstallIngressSchema,
destination: SkillInstallDestinationSchema,
+ /** Agents the user picked; absent means every detected agent. */
+ providers: z.array(z.string().min(1).max(64)).max(64).optional(),
conflictDecisions: z.array(SkillBundleConflictDecisionSchema).max(512).default([])
})
.strict()
diff --git a/src/shared/skill-cloud-contract.ts b/src/shared/skill-cloud-contract.ts
index bec7978e456..393c5cee252 100644
--- a/src/shared/skill-cloud-contract.ts
+++ b/src/shared/skill-cloud-contract.ts
@@ -67,6 +67,7 @@ export type SkillCloudPublishRequest = SkillCloudOptions & {
archiveSha256: string
compressedBytes: number
packageId: string
+ idempotencyKey?: string
releaseNotes: string
pinnedVersionId?: string
onProgress?: (progress: {
diff --git a/src/shared/skill-install-capability.ts b/src/shared/skill-install-capability.ts
index 0e10478d1d3..80feeb4fffb 100644
--- a/src/shared/skill-install-capability.ts
+++ b/src/shared/skill-install-capability.ts
@@ -5,6 +5,9 @@ export const SKILL_UPLOAD_CAPABILITY = 'skills.upload.v1' as const
export const SKILL_MANAGEMENT_CAPABILITY = 'skills.manage.v1' as const
export const SKILL_BUNDLE_INSTALL_CAPABILITY = 'skills.install.bundle.v1' as const
export const SKILL_INSTALL_PROGRESS_CAPABILITY = 'skills.install-progress.v1' as const
+/** Why: the install request schema is strict, so a host that predates the agent
+ * picker rejects the whole request rather than ignoring the new field. */
+export const SKILL_INSTALL_PROVIDERS_CAPABILITY = 'skills.install-providers.v1' as const
export const SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE =
'Update Orca on the selected machine to install shared skills.'
diff --git a/src/shared/skill-install-contract.ts b/src/shared/skill-install-contract.ts
index 03611ce2dcd..cab0294a97d 100644
--- a/src/shared/skill-install-contract.ts
+++ b/src/shared/skill-install-contract.ts
@@ -53,12 +53,16 @@ export const SkillInstallDestinationSchema = z.discriminatedUnion('scope', [
.refine((value) => Boolean(value.worktreeId) !== Boolean(value.folderWorkspaceId))
])
+/** Agents the user picked. Absent means every detected agent. */
+const SkillInstallProvidersSchema = z.array(z.string().min(1).max(64)).max(64)
+
export const SkillInstallRequestSchema = z
.object({
operationId: z.string().min(1).max(128),
package: SkillPackageIdentitySchema,
ingress: SkillInstallIngressSchema,
destination: SkillInstallDestinationSchema,
+ providers: SkillInstallProvidersSchema.optional(),
conflictResolution: z
.enum(['replace-unmodified', 'replace-and-discard-local', 'cancel'])
.optional()
@@ -116,6 +120,7 @@ export type ManagedSkillInstall = {
scope: 'global' | 'workspace'
destinationIdentity: string
destination: SkillInstallDestination
+ providers?: string[]
installedAt: string
state: 'unchanged' | 'modified' | 'missing'
}
@@ -132,6 +137,7 @@ export const ManagedSkillInstallSchema: z.ZodType = z.objec
scope: z.enum(['global', 'workspace']),
destinationIdentity: z.string(),
destination: SkillInstallDestinationSchema,
+ providers: SkillInstallProvidersSchema.optional(),
installedAt: z.string(),
state: z.enum(['unchanged', 'modified', 'missing'])
})
diff --git a/src/shared/skill-install-providers.ts b/src/shared/skill-install-providers.ts
new file mode 100644
index 00000000000..6341b0a80cf
--- /dev/null
+++ b/src/shared/skill-install-providers.ts
@@ -0,0 +1,115 @@
+/**
+ * Agents Orca can place an installed skill for.
+ *
+ * `null` segments mean the agent reads the canonical `.agents/skills` root at
+ * that scope, so it needs no placement of its own. Ids match the detection ids
+ * in `tui-agent-config.ts` so a detected agent maps straight to a destination.
+ */
+export type SkillInstallProviderId =
+ | 'codex'
+ | 'claude'
+ | 'cursor'
+ | 'gemini'
+ | 'droid'
+ | 'continue'
+ | 'trae'
+ | 'grok'
+ | 'aug'
+
+export type SkillInstallProviderDefinition = {
+ id: SkillInstallProviderId
+ displayName: string
+ globalSegments: readonly string[] | null
+ workspaceSegments: readonly string[] | null
+}
+
+export const SKILL_INSTALL_PROVIDERS: readonly SkillInstallProviderDefinition[] = [
+ // Why: Codex reads the canonical .agents/skills root at both scopes.
+ {
+ id: 'codex',
+ displayName: 'Codex',
+ globalSegments: null,
+ workspaceSegments: null
+ },
+ {
+ id: 'claude',
+ displayName: 'Claude Code',
+ globalSegments: ['.claude', 'skills'],
+ workspaceSegments: ['.claude', 'skills']
+ },
+ // Why: Cursor and Gemini read the canonical root inside a project but keep
+ // their own home directory, so they only need a placement at global scope.
+ {
+ id: 'cursor',
+ displayName: 'Cursor',
+ globalSegments: ['.cursor', 'skills'],
+ workspaceSegments: null
+ },
+ {
+ id: 'gemini',
+ displayName: 'Gemini CLI',
+ globalSegments: ['.gemini', 'skills'],
+ workspaceSegments: null
+ },
+ {
+ id: 'droid',
+ displayName: 'Droid',
+ globalSegments: ['.factory', 'skills'],
+ workspaceSegments: ['.factory', 'skills']
+ },
+ {
+ id: 'continue',
+ displayName: 'Continue',
+ globalSegments: ['.continue', 'skills'],
+ workspaceSegments: ['.continue', 'skills']
+ },
+ {
+ id: 'trae',
+ displayName: 'Trae',
+ globalSegments: ['.trae-cn', 'skills'],
+ workspaceSegments: ['.trae', 'skills']
+ },
+ {
+ id: 'grok',
+ displayName: 'Grok',
+ globalSegments: ['.grok', 'skills'],
+ workspaceSegments: ['.grok', 'skills']
+ },
+ {
+ id: 'aug',
+ displayName: 'Augment',
+ globalSegments: ['.augment', 'skills'],
+ workspaceSegments: ['.augment', 'skills']
+ }
+]
+
+const PROVIDERS_BY_ID = new Map(SKILL_INSTALL_PROVIDERS.map((provider) => [provider.id, provider]))
+
+export function isSkillInstallProviderId(value: string): value is SkillInstallProviderId {
+ return PROVIDERS_BY_ID.has(value as SkillInstallProviderId)
+}
+
+export function skillInstallProvider(
+ id: SkillInstallProviderId
+): SkillInstallProviderDefinition | undefined {
+ return PROVIDERS_BY_ID.get(id)
+}
+
+/** Detected agents Orca can actually place skills for, in registry order. */
+export function installableSkillProviders(
+ detectedProviders: readonly string[]
+): SkillInstallProviderDefinition[] {
+ const detected = new Set(detectedProviders)
+ return SKILL_INSTALL_PROVIDERS.filter((provider) => detected.has(provider.id))
+}
+
+/**
+ * An explicit choice is authoritative, including agents the target may install
+ * later. Removal passes no choice so it can clean every previously used root.
+ */
+export function selectedOrDetectedSkillProviders(
+ detectedProviders: readonly string[],
+ selectedProviders: readonly string[] | undefined
+): readonly string[] {
+ return selectedProviders ?? detectedProviders
+}
diff --git a/src/shared/skill-sharing-contract.ts b/src/shared/skill-sharing-contract.ts
index 7f614708055..6417a5bdd1f 100644
--- a/src/shared/skill-sharing-contract.ts
+++ b/src/shared/skill-sharing-contract.ts
@@ -80,6 +80,8 @@ export type SkillShareInstallInput = {
versionId?: string
environmentId?: string
destination: SkillInstallDestination
+ /** Agents to place the skill for; omitted means every detected agent. */
+ providers?: string[]
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
}
@@ -101,6 +103,8 @@ export type SkillBundleShareInstallInput = {
environmentId?: string
selectedSkillIds: string[]
destination: SkillInstallDestination
+ /** Agents to place the skills for; omitted means every detected agent. */
+ providers?: string[]
conflictDecisions?: {
skillId: string
resolution: 'keep-local' | 'replace-unmodified' | 'replace-and-discard-local'
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 0f7a6685f9a..47a6c14088c 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2952,6 +2952,8 @@ export type GlobalSettings = {
artifactSharingEnabled?: boolean
/** Only toggles the sidebar shortcut; Artifacts stay reachable from Settings. */
showArtifactsButton?: boolean
+ /** Only toggles the sidebar shortcut; Skills stay reachable from Settings. */
+ showSkillsButton?: boolean
/** Only toggles the sidebar shortcut; Orca Mobile stays reachable from Settings. */
showMobileButton?: boolean
/** Pinned workspaces show in one sidebar location by default; opt in to also show them in their natural groups. */