mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
feat(skills): add private skill bundle sharing
This commit is contained in:
@@ -7,8 +7,8 @@ Last updated: 2026-08-11.
|
||||
Implementation baselines captured by this checklist update:
|
||||
|
||||
- Orca implementation: `6ba6107dba` on `skills-share` (pushed; no PR).
|
||||
- Orca Cloud: `4e91cf9` on `main` (skill OIDC smoke in `stablyai/orca-cloud#313` and
|
||||
release-metadata convergence in `#314`).
|
||||
- Orca Cloud: `be00db10` on `main` (opaque audience IDs in `stablyai/orca-cloud#317`, with the
|
||||
skill OIDC smoke and release-metadata convergence from `#313` and `#314`).
|
||||
|
||||
Validated so far:
|
||||
|
||||
@@ -73,6 +73,16 @@ Validated so far:
|
||||
lifecycle; skill upload/finalize/download; two immutable versions; recipient and outsider
|
||||
authorization; local and remote grants; rollback selection; expiry; revocation; package
|
||||
deletion; and signed-object cleanup.
|
||||
- Cloud PR `#317` passed both required checks and merged as `be00db10`. Deploy run `31546194596`
|
||||
promoted revision `orca-cloud-api-staging-00048-xom` at 100% traffic with
|
||||
`authenticatedSmoke: true` and `skillSmoke: true`; canonical health and the immutable image
|
||||
digest independently matched after promotion.
|
||||
- The signed-in macOS desktop staging journey reached upload, finalize, durable share creation,
|
||||
and global install after replacing an invalid colon in the audience-derived idempotency key.
|
||||
The retry test now requires a stable path-safe key, and the live test records the package ID
|
||||
before publication so a failed share can still clean up its finalized package. Its update phase
|
||||
uncovered same-name discovery ambiguity; the harness now selects the exact workspace source
|
||||
directory instead of the first same-named global skill. A final full live rerun remains open.
|
||||
- The serving API has all four controls `true`, zero revision errors, and privacy-safe structured
|
||||
request logs. The log field inventory contains no principals, credentials, signed URLs, object
|
||||
paths, or private contents; a sensitive-value scan returned zero matches.
|
||||
@@ -80,9 +90,9 @@ Validated so far:
|
||||
`31536141357`, merged as `4e91cf9`, and passed `main` run `31536329459`. The targeted Auth/API
|
||||
plan then reported zero intended changes. Known SQL and artifact-bucket drift was excluded and
|
||||
never applied.
|
||||
- Guarded sleep run `31536547916` stopped all three Relay cells. Read-only status run
|
||||
`31537525219` verified Cloud SQL policy `NEVER`, every MIG target at zero, and both managed Cloud
|
||||
Run active-revision minimums at zero.
|
||||
- Guarded sleep run `31546908228` stopped all three Relay cells after the physical staging work.
|
||||
Independent GCP reads verified Cloud SQL policy `NEVER`, every MIG target at zero, and both
|
||||
managed Cloud Run minimums at zero.
|
||||
|
||||
Rollout gate: staging infrastructure, OIDC identity exchange, route registration, and the
|
||||
authenticated artifact/skill lifecycle smoke passed. The shared staging data plane is back in its
|
||||
@@ -97,14 +107,16 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] A user can package a private local skill, choose its audience, and receive a durable Orca
|
||||
share URL.
|
||||
- [ ] An authorized recipient can inspect and install an immutable version globally or into a Git
|
||||
worktree or plain folder workspace.
|
||||
- [ ] A user can select one or many private local skills, publish one immutable Skill Bundle, and
|
||||
receive one durable Orca share URL.
|
||||
- [ ] An authorized recipient can inspect a bundle, choose all or a subset of its skills, and
|
||||
install them globally or into a Git worktree or plain folder workspace.
|
||||
- [ ] Installation works on local macOS, Linux, native Windows, WSL, paired Orca runtimes, and
|
||||
supported SSH targets.
|
||||
- [x] One canonical `.agents/skills/<skill-name>` copy is installed; provider-specific placements
|
||||
are reconciled only for detected agents.
|
||||
- [ ] 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/<skill-name>` copy is installed for each selected skill;
|
||||
provider-specific placements are reconciled only for detected agents.
|
||||
- [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.
|
||||
@@ -114,6 +126,8 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Mixed-version clients and remote hosts fail safely through capability negotiation.
|
||||
- [ ] Cloud resources are Terraform-owned, monitored, recoverable, and protected by independent
|
||||
upload, download, and remote-install kill switches.
|
||||
- [ ] The existing Skills page supports multi-select sharing, selective installation, per-skill
|
||||
conflicts/results, and installed/shared bundle management.
|
||||
|
||||
## 0. Confirm scope and ownership
|
||||
|
||||
@@ -125,6 +139,13 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Decide Orca will not depend on the upstream CLI or an unsupported programmatic API.
|
||||
- [x] Decide V1 accepts Orca package sources only; Git, npm, and community registries remain with
|
||||
existing tools.
|
||||
- [x] Record Agent Plugins 1.0.0 commit `bd383552095128f6effe895b9257cfd580a6d179`
|
||||
as the portable-format reference.
|
||||
- [x] Decide to implement from the specification without copying its CC BY prose or vendoring its
|
||||
Apache-licensed schemas.
|
||||
- [x] Treat the archive as a Skill Bundle, not an Orca plugin product or executable plugin runtime.
|
||||
- [x] Limit V1 bundles to skills; exclude MCP servers, hooks, processes, connectors, and
|
||||
permissions.
|
||||
- [ ] Review the architecture decision record covering the reference-only boundary with
|
||||
engineering and legal owners.
|
||||
- [x] Add a review check that flags any proposed copied or mechanically translated upstream
|
||||
@@ -151,6 +172,14 @@ does not mean the surrounding phase is complete.
|
||||
unshare/delete—while retaining skill-specific version, ACL, trust, and install semantics.
|
||||
- [x] Confirm V1 has no checked-in workspace desired-state lockfile and no automatic fleet-wide
|
||||
installation.
|
||||
- [x] Confirm one skill is a one-item bundle and a bundle may contain large selections such as 30
|
||||
skills behind one durable link.
|
||||
- [x] Keep bundle identity, access, versions, transport, update/rollback, revocation, and deletion
|
||||
at bundle scope.
|
||||
- [x] Keep installation selection, conflicts, provenance, modification protection, and outcomes at
|
||||
skill scope.
|
||||
- [x] Keep loose-skill installation as the universal/default path and treat provider-native plugin
|
||||
adapters as optional compatibility outputs.
|
||||
|
||||
### Existing-code and provider research
|
||||
|
||||
@@ -171,11 +200,34 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
## 1. Define package and install contracts
|
||||
|
||||
### Bundle contract replacement
|
||||
|
||||
- [ ] Replace the unpublished single-skill envelope with root `plugin.json`, `skills/<name>/`, and
|
||||
`dev.orca.skill-sharing/manifest.json`; do not add an outer `bundle/` directory.
|
||||
- [x] Define `SkillBundleManifestV1` with stable package/version identity, bundle metadata, ordered
|
||||
skill entries, per-skill identity and file lists, globally unique archive paths, and a bundle
|
||||
digest.
|
||||
- [x] Validate Agent Plugins 1.0.0 `plugin.json` fields from independently authored local rules;
|
||||
never fetch a schema during loading.
|
||||
- [ ] Create fresh staging roots and reject unknown, malformed, or conflicting imported
|
||||
`dev.orca.skill-sharing` namespaces without overwriting them.
|
||||
- [x] Keep the detailed manifest inside the archive for export, remote/SSH transport, and offline
|
||||
validation; keep GCS metadata compact and PostgreSQL searchable.
|
||||
- [x] Add selected skill IDs and per-skill conflict choices to preview/install requests.
|
||||
- [x] Return aggregate status plus ordered installed, unchanged, kept-local, and failed per-skill
|
||||
outcomes; support retrying failed items only.
|
||||
- [x] Add `skills.install.bundle.v1` and additive bundle RPC methods so mixed-version peers fail
|
||||
before transfer.
|
||||
- [x] Decide to dispose of the unpublished staging-only single-skill records before changing the
|
||||
Cloud schema; there
|
||||
is no production compatibility commitment to the unpublished single-skill format.
|
||||
|
||||
### Shared package manifest
|
||||
|
||||
- [x] Add `src/shared/skill-package-manifest.ts`.
|
||||
- [x] Define `SkillPackageManifestV1` with schema version, stable package ID, immutable version ID,
|
||||
name, description, creation time, file identities, and package digest.
|
||||
- [x] Define the original single-skill `SkillPackageManifestV1` prototype with schema version,
|
||||
stable package ID, immutable version ID, name, description, creation time, file identities,
|
||||
and package digest.
|
||||
- [x] Normalize manifest paths to `/` while keeping filesystem conversion host-owned.
|
||||
- [x] Define the canonical digest algorithm over normalized paths, file identity, executable state,
|
||||
and classification.
|
||||
@@ -246,6 +298,12 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Include only `skill/` contents in the eventual installed directory.
|
||||
- [x] Bind the user-visible share preview to the final staged digest.
|
||||
- [x] Clean staging files on success, cancellation, source drift, and error.
|
||||
- [ ] Accept a non-empty ordered selection of skill directories and support at least 30 within the
|
||||
global entry/file/byte limits.
|
||||
- [ ] Reject duplicate normalized skill names and preserve selection across search/filter changes
|
||||
in the caller.
|
||||
- [x] Generate deterministic `plugin.json`, `skills/`, and Orca extension metadata.
|
||||
- [ ] Bind the share review to every staged skill digest and the final bundle digest.
|
||||
|
||||
### Bounded extraction
|
||||
|
||||
@@ -258,6 +316,9 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Convert manifest paths with the destination runtime's path APIs.
|
||||
- [x] Re-observe extracted `skill/` and compare every file identity and package digest.
|
||||
- [x] Delete partial extraction bytes on cancellation or failure.
|
||||
- [x] Validate the portable root, Orca extension namespace, bundle manifest, and every selected
|
||||
`skills/<name>` subtree before destination mutation.
|
||||
- [ ] Extract selected skill subtrees without requiring installation of unselected skills.
|
||||
|
||||
### Package tests
|
||||
|
||||
@@ -381,6 +442,12 @@ does not mean the surrounding phase is complete.
|
||||
installations.
|
||||
- [x] Detect an agent before creating its provider-specific configuration root.
|
||||
- [x] Avoid modifying roots for agents that consume `.agents/skills` directly.
|
||||
- [ ] Keep loose-skill placement as the universal/default installation path.
|
||||
- [ ] Use the portable root directly for compatible Cursor installations.
|
||||
- [ ] Generate validated `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json` adapters only
|
||||
for detected clients where native plugin installation is useful.
|
||||
- [ ] Fall back to loose skills for unsupported plugin clients, including the current Codex IDE
|
||||
extension, and retain bundle/version provenance for every installed skill.
|
||||
|
||||
### Placement reconciliation
|
||||
|
||||
@@ -551,6 +618,14 @@ does not mean the surrounding phase is complete.
|
||||
tenant-scoped archive and final GCS key/generation constraints.
|
||||
- [x] Add active-share, ACL-principal, and pending-upload expiration indexes.
|
||||
- [x] Add foreign keys preventing blob-reference deletion while a published version uses it.
|
||||
- [x] Accept and stream-validate both the pre-release single-skill envelope and the new Agent
|
||||
Plugins skills-only bundle envelope during the desktop migration.
|
||||
- [x] Store the detailed bundle manifest in the existing immutable version catalog and derive the
|
||||
searchable bundle name and digest without changing object or ACL semantics.
|
||||
- [ ] Adapt package/version records to bundle-level identity and add ordered per-version skill rows
|
||||
with identity, digest, counts, warnings, and archive roots.
|
||||
- [ ] Keep detailed file identity in the in-archive manifest rather than duplicating it into GCS
|
||||
custom metadata.
|
||||
- [ ] Test forward migration, rollback strategy, backup restoration, and migration compatibility
|
||||
during mixed API versions.
|
||||
|
||||
@@ -646,6 +721,16 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Return a copyable durable Orca URL after publication.
|
||||
- [x] Add access editing, version publishing, unshare, and package deletion actions.
|
||||
- [x] Make clear that unsharing blocks future installs but does not remove installed copies.
|
||||
- [ ] Add **Share skills**, **Install from link**, and unified **Manage** to the existing Skills page
|
||||
header. The first two are present; management still covers installed packages only.
|
||||
- [x] Add selection mode whose selection survives search/filter changes and an explicit **Select
|
||||
all results** action; never select all implicitly.
|
||||
- [x] Keep the per-card share action as a one-skill bundle shortcut.
|
||||
- [ ] Disable ineligible skills with visible reasons and block duplicate skill names before review.
|
||||
- [x] Review bundle name, skill/file/byte counts, script/executable warnings, expandable per-skill
|
||||
details, audience, and release notes.
|
||||
- [ ] Show named preparation, upload, verification, and link-publication progress and return one
|
||||
durable link for the bundle.
|
||||
|
||||
### Install experience
|
||||
|
||||
@@ -660,6 +745,16 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Render installed, unchanged, updated, partial, conflict, unsupported, cancelled, and failed
|
||||
results with actionable recovery.
|
||||
- [x] Add incomplete-coverage retry.
|
||||
- [ ] Open deep links in inspection mode without starting installation.
|
||||
- [x] Show publisher, organization, immutable version, skill count, scripts/executables, and
|
||||
release notes before selection.
|
||||
- [x] Let the recipient select all or a subset, then choose local, paired runtime, WSL, or SSH and
|
||||
global or workspace scope.
|
||||
- [x] Preview new, unchanged, update, and conflict counts and resolve all per-skill conflicts in one
|
||||
surface with **Keep local** as the default.
|
||||
- [ ] Label the primary action **Install N skills** and show aggregate progress plus current skill.
|
||||
The label and aggregate phases are implemented; current-skill progress is not yet emitted.
|
||||
- [x] Group results into installed, unchanged, kept local, and failed; retry failed items only.
|
||||
|
||||
### Skills page lifecycle
|
||||
|
||||
@@ -668,6 +763,13 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Show whether a package came from Orca Cloud and its accessible version history.
|
||||
- [x] Refresh discovery and installation state after local or remote actions.
|
||||
- [x] Keep Cloud deletion, share revocation, and local removal clearly separate.
|
||||
- [ ] Add installed-bundle update, rollback, install-on-another-machine, inspect, and remove actions.
|
||||
Grouped inspection, selective update/rollback, and one-surface safe removal are implemented;
|
||||
install-on-another-machine remains.
|
||||
- [ ] Add shared-bundle copy-link, access, revoke, publish-version, and Cloud-delete actions. Access,
|
||||
revoke, version/package deletion are bundle-compatible; copy-link and publish-version remain.
|
||||
- [x] Protect locally modified skills during bundle update and removal, including when only part of
|
||||
a bundle remains managed. Keep-local is the default and destructive replacement is explicit.
|
||||
|
||||
### UX validation
|
||||
|
||||
@@ -715,6 +817,8 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
### Mixed versions
|
||||
|
||||
- [x] Advertise additive bundle installation separately on paired and SSH hosts; never send the
|
||||
bundle method to an older peer that lacks `skills.install.bundle.v1`.
|
||||
- [x] Hide or disable remote install when `skills.install.v1` is absent.
|
||||
- [x] Show a specific host-update-required message instead of attempting the RPC.
|
||||
- [x] Test new client/old server and old client/new server.
|
||||
@@ -872,6 +976,8 @@ This does not substitute for physical SSH macOS/Windows or the supported Linux f
|
||||
- [x] If unsupported, offer a package download or explicit command; never install into the
|
||||
desktop's home as fallback.
|
||||
- [x] Propagate cancellation and clean partial SSH transfers and staging.
|
||||
- [x] Route selective bundle installs through an additive SSH method with direct download,
|
||||
client-mediated fallback, per-skill results, and old-host capability fencing.
|
||||
- [ ] Test SSH-only macOS, Linux at the supported floor, and Windows where the existing provider
|
||||
supports it.
|
||||
- [x] Test Git worktree and folder-workspace scope over SSH through the disposable Docker Linux
|
||||
@@ -1018,6 +1124,11 @@ still require the listed split metrics, latency panels, and alerts.
|
||||
- [ ] Run upload/finalize/download tests for expiry, denial, oversize, corruption, deduplication,
|
||||
revocation, deletion, and cleanup.
|
||||
- [ ] Run desktop-to-local, paired-runtime, `windows 2`, WSL, and SSH journeys in staging.
|
||||
The macOS desktop journey has live upload, finalize, share, and first-install evidence; its
|
||||
exact-source update, rollback, revocation, removal, and deletion rerun remains.
|
||||
- [ ] Give the live staging E2E an owner-private persisted test profile or a short-lived
|
||||
non-interactive test credential so reruns do not open PKCE login tabs or copy a user's
|
||||
primary Orca session.
|
||||
- [x] Verify staging skill request logs contain only route, method, status, duration, and standard
|
||||
process/request metadata, with zero credential or private-content matches.
|
||||
- [ ] Verify logs, metrics, traces, diagnostics, and support bundles contain no grants or private
|
||||
@@ -1027,23 +1138,22 @@ still require the listed split metrics, latency panels, and alerts.
|
||||
- [ ] Exercise at least one quarantine lifecycle deletion and published-object soft-delete
|
||||
recovery.
|
||||
|
||||
### Production infrastructure and internal rollout
|
||||
### 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.
|
||||
- [ ] Enable internal accounts only.
|
||||
- [ ] After every first-release gate passes, enable the feature for all accounts in one launch.
|
||||
- [ ] Verify one complete share, local install, remote install, update, rollback, revoke, local
|
||||
removal, Cloud deletion, upload expiry, and soft-delete recovery journey.
|
||||
- [ ] Verify upload, download, and remote-install kill switches independently.
|
||||
- [ ] Review error budgets, cost, authorization denials, saturation, orphan counts, and support
|
||||
signals.
|
||||
|
||||
### Gradual availability
|
||||
### Launch operations
|
||||
|
||||
- [ ] Expand by account/organization cohort with rollback criteria and an on-call owner.
|
||||
- [ ] Keep upload grants, download grants, and remote installation independently controllable.
|
||||
- [ ] Pause expansion on unexplained digest mismatch, archive containment failure, data leak,
|
||||
- [ ] Disable the affected operation on unexplained digest mismatch, archive containment failure, data leak,
|
||||
unrecoverable local mutation, or cross-tenant authorization defect.
|
||||
- [ ] Publish user documentation for sharing, access, install destinations, updates, rollback,
|
||||
removal, retention, and trust.
|
||||
|
||||
@@ -15,7 +15,8 @@ Provider registry:
|
||||
|
||||
## Decision summary
|
||||
|
||||
Orca will own a focused skill packaging and installation pipeline for private sharing.
|
||||
Orca will own a focused Skill Bundle packaging and installation pipeline for private sharing. A
|
||||
bundle contains one or many skills; sharing one skill is the same flow with one selected item.
|
||||
|
||||
The community [`vercel-labs/skills`](https://github.com/vercel-labs/skills) project will
|
||||
remain a behavioral reference, not a runtime dependency or source donor. Its npm package
|
||||
@@ -24,14 +25,22 @@ not provide the transaction, provenance, or private-package semantics Orca needs
|
||||
|
||||
Orca will:
|
||||
|
||||
1. Package a local skill into an immutable, content-addressed artifact.
|
||||
1. Package one or many local skills into one immutable, content-addressed artifact and durable
|
||||
link.
|
||||
2. Store private artifacts behind Orca Cloud authorization and durable share records.
|
||||
3. Resolve a share into a short-lived download grant after checking access.
|
||||
4. Execute installation on the machine that will use the skill.
|
||||
5. Validate, stage, commit, and verify the package without silently overwriting local work.
|
||||
6. Install once into `.agents/skills` and add aliases only for detected agents that need
|
||||
provider-specific paths.
|
||||
7. Record Orca-owned provenance independently from the community CLI's lockfiles.
|
||||
6. Let the recipient install all or a selected subset, with conflicts and results reported per
|
||||
skill.
|
||||
7. Install each selected skill once into `.agents/skills` and add aliases only for detected agents
|
||||
that need provider-specific paths.
|
||||
8. Record bundle/version provenance independently from the community CLI's lockfiles.
|
||||
|
||||
The portable archive root follows Agent Plugins 1.0.0 for a skills-only package. Orca does not
|
||||
present Skill Bundles as a new executable plugin system: MCP servers, hooks, processes, connectors,
|
||||
and permissions are out of scope. Loose-skill installation remains the universal/default delivery
|
||||
path, with provider-native plugin adapters generated only where they improve compatibility.
|
||||
|
||||
The first release will support Orca package sources only. Arbitrary Git, npm, and community
|
||||
registry sources remain delegated to existing tools until there is a product need to own
|
||||
@@ -39,14 +48,32 @@ that larger package-manager surface.
|
||||
|
||||
## Current execution status
|
||||
|
||||
Orca implementation through `6ba6107dba` is pushed on `skills-share` without an Orca pull request.
|
||||
Orca Cloud implementation is merged on `main` through `4e91cf9`. The authenticated OIDC smoke
|
||||
landed in `stablyai/orca-cloud#313`; the narrow Auth release-metadata convergence follow-up landed
|
||||
in `#314`. Staging infrastructure and skill routes are deployed and validated; production remains
|
||||
untouched. Local, Windows, WSL, paired-runtime, and
|
||||
The last pushed Orca baseline is `41a7b45c4e`; current Skill Bundle work remains uncommitted on
|
||||
`skills-share` and no Orca pull request exists. Orca Cloud implementation is merged on `main`
|
||||
through `be00db10`. The authenticated OIDC smoke landed in `stablyai/orca-cloud#313`; the narrow
|
||||
Auth release-metadata convergence follow-up landed in `#314`; the finalization and lifecycle fixes
|
||||
landed in `#317`. Staging infrastructure and skill routes are deployed and validated; production
|
||||
remains untouched. Local, Windows, WSL, paired-runtime, and
|
||||
Docker-backed SSH validation are substantially complete; the implementation checklist records the
|
||||
exact evidence and remaining physical-host and failure-recovery gates.
|
||||
|
||||
The local redesign now has an independently implemented Agent Plugins 1.0.0 skills-only bundle
|
||||
manifest, deterministic one-or-many archive creation, bounded bundle extraction, additive bundle
|
||||
install contracts, per-skill conflict/results orchestration, and bundle provenance on installed
|
||||
skill receipts. The recipient dialog now recognizes bundle versions, defaults to all skills,
|
||||
supports subset selection, previews aggregate destination state, keeps conflicts local by default,
|
||||
and groups per-skill outcomes with failed-only retry. Local and paired-runtime bundle grants use the
|
||||
additive bundle RPC with staged-upload fallback. SSH now uses a separately advertised additive
|
||||
bundle relay method with the same direct-download and staged-upload fallback; older hosts are
|
||||
rejected before transfer. The original single-skill path remains active for legacy shares while
|
||||
bundle management migrates.
|
||||
|
||||
Cloud bundle validation is pushed on isolated branch `feat/skill-bundles` at `d7d1026`. It accepts
|
||||
both envelopes during migration, stores the detailed bundle manifest with the immutable version,
|
||||
and leaves object storage, ACLs, shares, and download grants unchanged. The full API suite passes
|
||||
136 tests with one skipped. It is not merged or deployed until the desktop publisher emits the new
|
||||
format and the staging smoke is updated.
|
||||
|
||||
The latest resilience pass adds bounded convergence after a lost final install response for both
|
||||
paired runtimes and SSH. A staged retry creates a new upload instead of reusing a consumed upload.
|
||||
SSH now carries only schema-validated structured skill failures through optional JSON-RPC
|
||||
@@ -84,6 +111,15 @@ are end-user install journeys, load, quarantine lifecycle deletion, and soft-del
|
||||
The upstream assessment used `vercel-labs/skills` commit
|
||||
`c6f69c631292444cc541ac6d91e2226b0ff247da`.
|
||||
|
||||
The portable bundle assessment used
|
||||
[`agentplugins/agent-plugins-spec`](https://github.com/agentplugins/agent-plugins-spec) commit
|
||||
`bd383552095128f6effe895b9257cfd580a6d179`, specifically `spec/1.0.0.md`. The local reviewed clone
|
||||
is `/Users/jinwoo/refs/misc/agent-plugins-spec`; the example repository is
|
||||
`/Users/jinwoo/refs/misc/agent-plugins-example`. Orca independently implements the reviewed
|
||||
behavior. The specification prose is CC BY 4.0 and its schemas/software are Apache 2.0, so Orca
|
||||
will not copy the prose or vendor the schemas unless the corresponding notices are deliberately
|
||||
handled.
|
||||
|
||||
Useful upstream behavior:
|
||||
|
||||
- A canonical `.agents/skills/<name>` location.
|
||||
@@ -108,7 +144,8 @@ attribution. Revisit this decision if implementation work ever proposes copying
|
||||
|
||||
## Goals
|
||||
|
||||
- Share a private skill with a teammate through a durable, intuitive link.
|
||||
- Share one or many private skills with a teammate through one durable, intuitive link.
|
||||
- Select and review large bundles, including groups of 30 skills, without repetitive dialogs.
|
||||
- Install on the local computer or any compatible connected Orca runtime.
|
||||
- Support global scope and folder-workspace/project scope.
|
||||
- Work on macOS, Linux, and Windows.
|
||||
@@ -122,6 +159,7 @@ attribution. Revisit this decision if implementation work ever proposes copying
|
||||
- Support immutable versions, updates, removal, and eventual team-wide reconciliation.
|
||||
- Remain compatible with independently updated desktop clients and remote Orca servers.
|
||||
- Treat ordinary folder workspaces as first-class; Git is not required.
|
||||
- Preserve enough portable metadata for export, SSH/remote transfer, and offline validation.
|
||||
|
||||
## Non-goals for the first release
|
||||
|
||||
@@ -132,6 +170,9 @@ attribution. Revisit this decision if implementation work ever proposes copying
|
||||
- Merging local modifications with a newer shared package version.
|
||||
- Treating installation as a security sandbox. A skill contains instructions and scripts and
|
||||
must be presented to the user as code from its author.
|
||||
- Shipping a full plugin runtime, MCP servers, hooks, processes, connectors, or portable
|
||||
permissions.
|
||||
- Requiring a provider-native plugin installer; unsupported agents still receive loose skills.
|
||||
- End-to-end encryption from Orca Cloud operators in the initial trust model. Private means
|
||||
access-controlled to authorized Orca users and organizations. Application-level package
|
||||
encryption can be added later without changing package identity.
|
||||
@@ -140,19 +181,26 @@ attribution. Revisit this decision if implementation work ever proposes copying
|
||||
|
||||
### Sharing
|
||||
|
||||
The user opens an installed or workspace skill and chooses **Share skill**.
|
||||
The existing Skills page has three header actions: **Share skills**, **Install from link**, and
|
||||
**Manage**. **Share skills** enters selection mode across installed and workspace skills. Search
|
||||
and filter changes retain selections; **Select all results** is explicit and Orca never silently
|
||||
selects everything. The existing per-card share action remains a one-skill shortcut.
|
||||
|
||||
Orca shows:
|
||||
Ineligible skills are disabled with a visible reason. Duplicate skill names must be resolved before
|
||||
publication because they would collide in both the archive and destination roots.
|
||||
|
||||
- Skill name and description.
|
||||
- Package file count and total size.
|
||||
- Included scripts and executable files.
|
||||
The review dialog shows:
|
||||
|
||||
- Bundle name and selected skill list.
|
||||
- Skill, file, and byte counts.
|
||||
- Included scripts and executable files, with an expandable review for each skill.
|
||||
- Current author identity and organization.
|
||||
- Access choice: organization or selected people.
|
||||
- Optional version label and release notes.
|
||||
|
||||
After confirmation, Orca validates and packages the exact bytes shown in the preview, uploads
|
||||
them, and returns a durable URL such as:
|
||||
After confirmation, Orca validates and packages the exact bytes shown in the preview. Progress is
|
||||
named **Preparing skills**, **Uploading**, **Verifying**, and **Publishing link**. Completion returns
|
||||
one durable URL such as:
|
||||
|
||||
```text
|
||||
https://app.orca.dev/skills/share/<share-id>
|
||||
@@ -165,32 +213,33 @@ copied.
|
||||
|
||||
### Installing
|
||||
|
||||
Opening a share shows:
|
||||
Opening a deep link launches Orca and inspects the bundle without installing it. Preview identifies
|
||||
the publisher, organization, immutable version, release notes, skill count, scripts, and executable
|
||||
files. The recipient can select all or a subset of the contained skills.
|
||||
|
||||
- Author and organization.
|
||||
- Skill description, version, file summary, and package digest.
|
||||
- Whether an installed skill with the same name already exists.
|
||||
- Destination machine.
|
||||
- Global or workspace scope.
|
||||
- Detected agents that will receive coverage.
|
||||
- Any conflict that requires a decision.
|
||||
The destination picker supports the local machine, paired runtimes, WSL, and SSH, in global or
|
||||
workspace scope. Before commit, the preview groups selected skills as new, unchanged, updates, or
|
||||
conflicts and shows detected-agent coverage. Conflicts are handled per skill in one review surface,
|
||||
not one dialog per conflict. **Keep local** is the default; replacement is explicit. The primary
|
||||
action says **Install N skills**.
|
||||
|
||||
The default action installs on the current machine globally. Connected machines and folder
|
||||
workspaces are selectable. The action reports one of:
|
||||
Installation shows aggregate progress plus the current skill. Results are grouped as installed,
|
||||
unchanged, kept local, and failed; failed items can be retried without repeating successful work.
|
||||
An incompatible selected runtime receives an update-required result before transfer.
|
||||
|
||||
- Installed.
|
||||
- Already installed.
|
||||
- Updated.
|
||||
- Installed with incomplete agent coverage.
|
||||
- Needs a conflict decision.
|
||||
- Unsupported because the selected runtime must be updated.
|
||||
**Manage** separates:
|
||||
|
||||
- Installed bundles: update, rollback, install on another machine, inspect skills, and remove.
|
||||
- Shared bundles: copy link, change access, revoke, publish version, and delete the Cloud package.
|
||||
|
||||
Bundle update or removal never silently destroys locally modified skills.
|
||||
|
||||
The UI must follow `docs/STYLEGUIDE.md` and use existing design tokens and shadcn primitives.
|
||||
|
||||
## Target architecture
|
||||
|
||||
```text
|
||||
Skill folder
|
||||
Selected skill folders
|
||||
|
|
||||
v
|
||||
Package builder -- validates and creates immutable artifact
|
||||
@@ -206,10 +255,10 @@ Destination runtime
|
||||
|
|
||||
+-- package ingress
|
||||
+-- package inspection
|
||||
+-- install planning
|
||||
+-- per-skill selection and install planning
|
||||
+-- staged transaction
|
||||
+-- provider placement reconciliation
|
||||
+-- provenance publication
|
||||
+-- bundle and per-skill provenance publication
|
||||
+-- post-install discovery
|
||||
```
|
||||
|
||||
@@ -220,59 +269,89 @@ the bytes.
|
||||
## Package format
|
||||
|
||||
Use a versioned tar archive so executable modes can be represented without a platform-specific
|
||||
side channel. The archive contains an envelope rather than placing Orca metadata inside the
|
||||
installed skill:
|
||||
side channel. The extracted archive root is an Agent Plugins 1.0.0-compatible, skills-only plugin:
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
skill/
|
||||
SKILL.md
|
||||
scripts/
|
||||
references/
|
||||
assets/
|
||||
plugin.json
|
||||
skills/
|
||||
├── skill-a/
|
||||
│ └── SKILL.md
|
||||
├── skill-b/
|
||||
│ └── SKILL.md
|
||||
└── ...
|
||||
dev.orca.skill-sharing/
|
||||
└── manifest.json
|
||||
```
|
||||
|
||||
Conceptual manifest:
|
||||
|
||||
```ts
|
||||
type SkillPackageManifestV1 = {
|
||||
type SkillBundleManifestV1 = {
|
||||
schemaVersion: 1
|
||||
packageId: string
|
||||
versionId: string
|
||||
name: string
|
||||
bundleName: string
|
||||
description: string
|
||||
createdAt: string
|
||||
files: Array<{
|
||||
path: string
|
||||
size: number
|
||||
executable: boolean
|
||||
sha256: string
|
||||
skills: Array<{
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
digest: string
|
||||
files: Array<{
|
||||
path: string
|
||||
size: number
|
||||
executable: boolean
|
||||
sha256: string
|
||||
}>
|
||||
}>
|
||||
packageDigest: string
|
||||
bundleDigest: string
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `packageId` is stable across versions; `versionId` identifies one immutable publication.
|
||||
- `packageDigest` is derived from normalized paths, executable state, classification, and file
|
||||
identity using Orca's existing skill-package identity rules.
|
||||
- `skill/SKILL.md` is required and must parse successfully.
|
||||
- The installed folder receives only the contents of `skill/`.
|
||||
- `plugin.json` uses the canonical Agent Plugins 1.0.0 `$schema`, a valid portable plugin name,
|
||||
bundle version, description, and optional standard metadata. Orca validates from locally owned
|
||||
rules and never fetches the schema while loading.
|
||||
- Every immediate `skills/<name>/SKILL.md` is required and must parse successfully.
|
||||
- Each skill digest uses normalized relative paths, executable state, classification, and file
|
||||
identity. The bundle digest commits to the ordered skill identities and their globally unique
|
||||
archive paths.
|
||||
- The installed folders receive only the selected children of `skills/`.
|
||||
- Bundle identity, access, immutable versions, upload/download, update/rollback, and Cloud deletion
|
||||
are bundle-level. Conflict handling, provenance, selection, local-modification protection, and
|
||||
results are skill-level.
|
||||
- Paths use `/` in the manifest and are converted with the executing host's path API.
|
||||
- LF and CRLF non-executable text share one normalized package identity, while each immutable
|
||||
archive retains and hashes its exact source bytes; executable and binary files always use exact
|
||||
bytes for identity.
|
||||
- Names are normalized once during publication. Installation never silently renames a conflict.
|
||||
- The archive has no outer `bundle/` wrapper, so its root remains directly consumable by agents
|
||||
that implement Agent Plugins.
|
||||
- Orca-specific integrity and version data lives in `dev.orca.skill-sharing/manifest.json` rather
|
||||
than a top-level custom field or GCS metadata alone. It therefore survives export, SSH/remote
|
||||
transfer, and offline validation and can describe every skill and file.
|
||||
- GCS custom metadata stays compact and operational. PostgreSQL remains the searchable package,
|
||||
share, and version catalog.
|
||||
- Orca constructs a fresh staging root. If imported content already contains
|
||||
`dev.orca.skill-sharing`, Orca accepts only an appropriate recognized schema and rejects unknown,
|
||||
malformed, or conflicting contents; it never overwrites the namespace silently.
|
||||
- V1 rejects symlinks and special files. A later package-builder feature may safely dereference
|
||||
internal links after proving they remain within the source root.
|
||||
- Existing limits remain the starting contract: depth 16, 2,048 entries, 512 files, 4 MiB per
|
||||
file, and 32 MiB total extracted bytes. Compressed download size also receives an explicit cap.
|
||||
- Archive and manifest schema changes are additive or introduced as a new schema version.
|
||||
|
||||
The package builder observes the source, copies it into a private staging directory, observes
|
||||
the staged copy again, and only packages it if the two identities agree. The preview shown to
|
||||
the user is bound to that final digest.
|
||||
The package builder observes every selected source, copies it into a fresh private staging root,
|
||||
observes the staged copies again, and only packages them if every identity agrees. The preview
|
||||
shown to the user is bound to that final bundle digest.
|
||||
|
||||
The original unpublished `manifest.json + skill/` staging format has no production compatibility
|
||||
commitment. Orca will replace it before launch and migrate or discard staging-only records. Remote
|
||||
bundle RPCs use a new `skills.install.bundle.v1` capability and additive methods so older peers do
|
||||
not interpret bundle requests as single-skill requests.
|
||||
|
||||
## GCP deployment plan
|
||||
|
||||
@@ -439,10 +518,13 @@ No long-lived GCP key is shipped in Orca or stored on a remote runtime.
|
||||
|
||||
Create migrations for:
|
||||
|
||||
- `skill_packages`: stable package identity, owner organization, slug/name, creator, timestamps,
|
||||
- `skill_packages`: stable bundle identity, owner organization, slug/name, creator, timestamps,
|
||||
and deletion state.
|
||||
- `skill_package_versions`: immutable version, package digest, archive SHA-256, GCS object key and
|
||||
generation, byte counts, manifest JSON, release notes, creator, and publication state.
|
||||
generation, byte and skill counts, compact searchable metadata, release notes, creator, and
|
||||
publication state.
|
||||
- `skill_package_version_skills`: ordered per-skill identity, name, description, digest, file and
|
||||
byte counts, warnings, and archive root for preview and selective installation.
|
||||
- `skill_package_uploads`: tenant/user binding, quarantine key and generation, expected identities,
|
||||
expiration, finalization state, and failure category.
|
||||
- `skill_package_acl`: organization or user principal, permission, creator, and timestamps.
|
||||
@@ -566,16 +648,16 @@ policies, signed URLs, or ACL membership.
|
||||
startup applies PostgreSQL migrations without registering the skill routes.
|
||||
7. Verify the migration-ready event, zero error logs, ordinary artifact behavior, and `404` skill
|
||||
route boundary before changing any control.
|
||||
8. Enable route registration for the staging test cohort and run upload/finalize/download tests,
|
||||
8. Enable route registration for the staging identity and run upload/finalize/download tests,
|
||||
including expired policies, ACL denial, oversized uploads, corrupt archives, deduplication,
|
||||
revocation, and object cleanup.
|
||||
9. Run a staging desktop-to-runtime installation and verify logs contain no grants or private
|
||||
contents.
|
||||
10. Apply the reviewed production Terraform plan.
|
||||
11. Run migrations, deploy the API, and enable internal accounts only.
|
||||
12. Observe at least one complete soft-delete, upload-expiry, update, rollback, and revoke journey.
|
||||
13. Expand the feature flag gradually, retaining separate kill switches for upload grants,
|
||||
download grants, and remote installation.
|
||||
11. Run migrations and deploy the API with availability disabled.
|
||||
12. After every release gate passes, enable the feature for all accounts in one launch.
|
||||
13. Observe at least one complete soft-delete, upload-expiry, update, rollback, and revoke journey,
|
||||
retaining separate kill switches for upload grants, download grants, and remote installation.
|
||||
|
||||
Production verification uses read-only commands such as:
|
||||
|
||||
@@ -661,14 +743,30 @@ Policy:
|
||||
Provider releases and official documentation are reviewed periodically. Orca's registry changes
|
||||
only through normal review and platform tests; no upstream synchronization script owns it.
|
||||
|
||||
The portable root does not imply every agent can install it as a native plugin. Cursor can consume
|
||||
the root `plugin.json` package directly. Claude expects `.claude-plugin/plugin.json`; Codex and
|
||||
ChatGPT expect `.codex-plugin/plugin.json`; the Codex IDE extension does not currently support
|
||||
plugins. Orca therefore installs loose skills by default and may generate provider-native adapters
|
||||
for detected compatible clients:
|
||||
|
||||
- Cursor: use the portable package directly.
|
||||
- Claude: generate `.claude-plugin/plugin.json` from validated bundle metadata.
|
||||
- Codex/ChatGPT: generate `.codex-plugin/plugin.json` from validated bundle metadata.
|
||||
- Other or unsupported agents: install the selected loose skills.
|
||||
|
||||
Native plugin installation never becomes the only path. Provenance connects every loose installed
|
||||
skill to its source bundle and immutable version.
|
||||
|
||||
## Installer components
|
||||
|
||||
Create narrow modules with explicit responsibilities:
|
||||
|
||||
### Shared contracts
|
||||
|
||||
- `src/shared/skill-package-manifest.ts`: package manifest schema and canonical validation.
|
||||
- `src/shared/skill-install-contract.ts`: request, preview, conflict, placement, and result types.
|
||||
- `src/shared/skill-package-manifest.ts`: bundle manifest, portable plugin manifest, and canonical
|
||||
validation.
|
||||
- `src/shared/skill-install-contract.ts`: bundle selection, per-skill preview, conflict, placement,
|
||||
and result types.
|
||||
- `src/shared/skill-install-capability.ts`: runtime capability name and compatibility message.
|
||||
|
||||
### Main/runtime implementation
|
||||
@@ -716,27 +814,29 @@ neutral.
|
||||
|
||||
### Renderer
|
||||
|
||||
- Share preview and access dialog.
|
||||
- Multi-select bundle share review and access dialog.
|
||||
- Share completion dialog with copyable durable link.
|
||||
- Access editing, active-link revocation, immutable-version deletion, and Cloud-package deletion
|
||||
with explicit confirmation and copy explaining that installed copies remain local.
|
||||
- Install preview with destination, scope, coverage, and conflict state.
|
||||
- Install progress and structured outcome.
|
||||
- Installed version, update, removal, and incomplete-coverage actions on the Skills page.
|
||||
- Selective install preview with destination, scope, coverage, and per-skill conflict state.
|
||||
- Aggregate install progress and grouped per-skill outcomes.
|
||||
- Installed/shared bundle management, update, rollback, removal, and incomplete-coverage actions on
|
||||
the Skills page.
|
||||
|
||||
## Install request and result contract
|
||||
|
||||
Conceptual request:
|
||||
|
||||
```ts
|
||||
type SkillInstallRequest = {
|
||||
type SkillBundleInstallRequest = {
|
||||
operationId: string
|
||||
package: {
|
||||
packageId: string
|
||||
versionId: string
|
||||
packageDigest: string
|
||||
bundleDigest: string
|
||||
compressedBytes: number
|
||||
}
|
||||
selectedSkillIds: string[]
|
||||
ingress:
|
||||
| { kind: 'download-grant'; url: string; expiresAt: string }
|
||||
| { kind: 'staged-upload'; uploadId: string }
|
||||
@@ -744,7 +844,10 @@ type SkillInstallRequest = {
|
||||
destination:
|
||||
| { scope: 'global'; environmentId?: string }
|
||||
| { scope: 'workspace'; worktreeId?: string; folderWorkspaceId?: string }
|
||||
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
|
||||
conflictResolutions?: Record<
|
||||
string,
|
||||
'keep-local' | 'replace-unmodified' | 'replace-and-discard-local'
|
||||
>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -754,24 +857,30 @@ arbitrary remote RPC path.
|
||||
Conceptual result:
|
||||
|
||||
```ts
|
||||
type SkillInstallResult = {
|
||||
type SkillBundleInstallResult = {
|
||||
operationId: string
|
||||
status: 'installed' | 'updated' | 'unchanged' | 'conflict' | 'partial' | 'failed'
|
||||
name: string
|
||||
packageDigest: string
|
||||
canonicalPath?: string
|
||||
placements: Array<{
|
||||
provider: string
|
||||
path: string
|
||||
topology: 'canonical-copy' | 'provider-alias' | 'independent-copy'
|
||||
status: 'installed' | 'unchanged' | 'skipped' | 'failed'
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleDigest: string
|
||||
status: 'complete' | 'partial' | 'failed'
|
||||
skills: Array<{
|
||||
skillId: string
|
||||
name: string
|
||||
status: 'installed' | 'updated' | 'unchanged' | 'kept-local' | 'failed'
|
||||
canonicalPath?: string
|
||||
placements: Array<{
|
||||
provider: string
|
||||
path: string
|
||||
topology: 'canonical-copy' | 'provider-alias' | 'independent-copy'
|
||||
status: 'installed' | 'unchanged' | 'skipped' | 'failed'
|
||||
errorCategory?: string
|
||||
}>
|
||||
conflict?: {
|
||||
kind: 'modified' | 'unowned' | 'external-link' | 'name-collision'
|
||||
existingDigest?: string
|
||||
}
|
||||
errorCategory?: string
|
||||
}>
|
||||
conflict?: {
|
||||
kind: 'modified' | 'unowned' | 'external-link' | 'name-collision'
|
||||
existingDigest?: string
|
||||
}
|
||||
errorCategory?: string
|
||||
}
|
||||
```
|
||||
|
||||
@@ -781,15 +890,15 @@ The response never includes a download grant or credentials.
|
||||
|
||||
### 1. Admission
|
||||
|
||||
- Validate all request fields and package identifiers.
|
||||
- Validate all request fields, package identifiers, selected skill IDs, and per-skill decisions.
|
||||
- Resolve the destination through host-owned runtime state.
|
||||
- Confirm the requested scope is writable and contained in an allowed home or workspace root.
|
||||
- Reject an expired grant before network access.
|
||||
- Deduplicate retries using `operationId` and the package/destination identity.
|
||||
- Deduplicate retries using `operationId`, package/version, selected skill set, and destination.
|
||||
|
||||
### 2. Lock
|
||||
|
||||
- Acquire a cross-process lock scoped to canonical destination and skill name.
|
||||
- Acquire locks for selected canonical destinations in deterministic skill-name order.
|
||||
- Use an atomic lock-directory or exclusive-file creation supported on all target platforms.
|
||||
- Record a random owner token and start time.
|
||||
- Recover a stale lock only after validating its journal and owner liveness policy.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
import { SkillBundleInstallRequestSchema } from '../../shared/skill-bundle-install-contract'
|
||||
import { SkillInstallDestinationSchema } from '../../shared/skill-install-contract'
|
||||
|
||||
export const skillCloudInstallEnvironmentIdSchema = z.string().min(1).max(128)
|
||||
|
||||
const installDestinationFields = {
|
||||
operationId: z.string().min(1).max(128).optional(),
|
||||
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictResolution: z
|
||||
.enum(['replace-unmodified', 'replace-and-discard-local', 'cancel'])
|
||||
.optional()
|
||||
} as const
|
||||
|
||||
export const skillCloudShareInstallSchema = z
|
||||
.object({
|
||||
shareId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128).optional(),
|
||||
...installDestinationFields
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const skillCloudBundleShareInstallSchema = z
|
||||
.object({
|
||||
shareId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128).optional(),
|
||||
operationId: z.string().min(1).max(128).optional(),
|
||||
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
|
||||
selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds,
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const skillCloudPackageVersionInstallSchema = z
|
||||
.object({
|
||||
packageId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128),
|
||||
...installDestinationFields
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const skillCloudBundlePackageVersionInstallSchema = z
|
||||
.object({
|
||||
packageId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128),
|
||||
operationId: z.string().min(1).max(128).optional(),
|
||||
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
|
||||
selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds,
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional()
|
||||
})
|
||||
.strict()
|
||||
@@ -3,24 +3,35 @@ import { join } from 'node:path'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability'
|
||||
import { SkillInstallDestinationSchema } from '../../shared/skill-install-contract'
|
||||
import type { SkillInstallProgress } from '../../shared/skill-sharing-contract'
|
||||
import { SkillDiscoveryTargetSchema, type SkillDiscoveryResult } from '../../shared/skills'
|
||||
import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { installSkillCloudGrant } from '../skills/skill-cloud-grant-installation'
|
||||
import {
|
||||
installSkillBundleCloudGrant,
|
||||
installSkillCloudGrant
|
||||
} from '../skills/skill-cloud-grant-installation'
|
||||
import { SkillRemoteInstallCancellation } from '../skills/skill-remote-install-cancellation'
|
||||
import { classifySkillCloudInstallTarget } from '../skills/skill-cloud-install-target'
|
||||
import { SkillSharePreparationService } from '../skills/skill-share-preparation-service'
|
||||
import { supportsSkillRuntimeInstall } from '../skills/skill-runtime-capability'
|
||||
import {
|
||||
supportsSkillRuntimeBundleInstall,
|
||||
supportsSkillRuntimeInstall
|
||||
} from '../skills/skill-runtime-capability'
|
||||
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
|
||||
import { registerSkillInstallManagementIpcHandlers } from './skill-install-management-ipc-handlers'
|
||||
|
||||
const environmentIdSchema = z.string().min(1).max(128)
|
||||
import {
|
||||
skillCloudBundlePackageVersionInstallSchema,
|
||||
skillCloudBundleShareInstallSchema,
|
||||
skillCloudInstallEnvironmentIdSchema,
|
||||
skillCloudPackageVersionInstallSchema,
|
||||
skillCloudShareInstallSchema
|
||||
} from './skill-cloud-install-ipc-schemas'
|
||||
|
||||
const sharePrepareSchema = z
|
||||
.object({
|
||||
skillId: z.string().min(1).max(4096),
|
||||
skillIds: z.array(z.string().min(1).max(4096)).min(1).max(512),
|
||||
bundleName: z.string().regex(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/),
|
||||
target: SkillDiscoveryTargetSchema.optional(),
|
||||
packageId: z.string().min(1).max(128).optional()
|
||||
})
|
||||
@@ -30,40 +41,15 @@ const sharePublishSchema = z
|
||||
.object({
|
||||
preparationId: z.string().uuid(),
|
||||
releaseNotes: z.string().max(10_000),
|
||||
userIds: z.array(z.string().min(1).max(128)).max(100),
|
||||
userIds: z.array(z.string().min(1).max(255)).max(100),
|
||||
shareWithOrganization: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const installDestinationFields = {
|
||||
operationId: z.string().min(1).max(128).optional(),
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictResolution: z
|
||||
.enum(['replace-unmodified', 'replace-and-discard-local', 'cancel'])
|
||||
.optional()
|
||||
} as const
|
||||
|
||||
const shareInstallSchema = z
|
||||
.object({
|
||||
shareId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128).optional(),
|
||||
...installDestinationFields
|
||||
})
|
||||
.strict()
|
||||
|
||||
const packageVersionInstallSchema = z
|
||||
.object({
|
||||
packageId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128),
|
||||
...installDestinationFields
|
||||
})
|
||||
.strict()
|
||||
|
||||
const replaceAccessSchema = z
|
||||
.object({
|
||||
packageId: z.string().min(1).max(128),
|
||||
userIds: z.array(z.string().min(1).max(128)).max(100),
|
||||
userIds: z.array(z.string().min(1).max(255)).max(100),
|
||||
shareWithOrganization: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
@@ -81,17 +67,24 @@ function registerSharingHandlers(
|
||||
): void {
|
||||
const preparations = new SkillSharePreparationService(
|
||||
join(app.getPath('userData'), 'skill-share-preparations'),
|
||||
{ publish: (request) => runtime.publishSkillPackage(request) }
|
||||
{
|
||||
publishVersion: (request) => runtime.publishSkillPackageVersion(request),
|
||||
createShare: (packageId, request) => runtime.createSkillPackageShare(packageId, request)
|
||||
}
|
||||
)
|
||||
ipcMain.handle('skills:prepareShare', async (_event, value: unknown) => {
|
||||
const input = sharePrepareSchema.parse(value)
|
||||
const result = await discover(input.target)
|
||||
const skill = result.skills.find((candidate) => candidate.id === input.skillId)
|
||||
if (!skill) {
|
||||
const requested = new Set(input.skillIds)
|
||||
const skills = result.skills.filter((candidate) => requested.has(candidate.id))
|
||||
if (skills.length !== requested.size) {
|
||||
throw new Error('skill-share-source-not-found')
|
||||
}
|
||||
return preparations.prepare({
|
||||
sourceDirectory: skill.directoryPath,
|
||||
sources: skills.map((skill) => ({ id: skill.name, sourceDirectory: skill.directoryPath })),
|
||||
bundleName: input.bundleName,
|
||||
description:
|
||||
skills.length === 1 ? (skills[0].description ?? '') : `${skills.length} shared skills`,
|
||||
packageId: input.packageId
|
||||
})
|
||||
})
|
||||
@@ -117,7 +110,9 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
const remoteInstallCancellation = new SkillRemoteInstallCancellation()
|
||||
const installAuthorizedGrant = async (
|
||||
grant: SkillCloudDownloadGrant,
|
||||
input: z.infer<typeof shareInstallSchema> | z.infer<typeof packageVersionInstallSchema>
|
||||
input:
|
||||
| z.infer<typeof skillCloudShareInstallSchema>
|
||||
| z.infer<typeof skillCloudPackageVersionInstallSchema>
|
||||
) => {
|
||||
if (!input.environmentId || input.environmentId.startsWith('ssh:')) {
|
||||
return installSkillCloudGrant(runtime, grant, {
|
||||
@@ -133,6 +128,26 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
remoteInstallCancellation.finish(operationId, signal)
|
||||
}
|
||||
}
|
||||
const installAuthorizedBundleGrant = async (
|
||||
grant: SkillCloudDownloadGrant,
|
||||
input:
|
||||
| z.infer<typeof skillCloudBundleShareInstallSchema>
|
||||
| z.infer<typeof skillCloudBundlePackageVersionInstallSchema>
|
||||
) => {
|
||||
if (!input.environmentId || input.environmentId.startsWith('ssh:')) {
|
||||
return installSkillBundleCloudGrant(runtime, grant, {
|
||||
...input,
|
||||
operationId: input.operationId ?? randomUUID()
|
||||
})
|
||||
}
|
||||
const operationId = input.operationId ?? randomUUID()
|
||||
const signal = remoteInstallCancellation.begin(operationId)
|
||||
try {
|
||||
return await installSkillBundleCloudGrant(runtime, grant, { ...input, operationId }, signal)
|
||||
} finally {
|
||||
remoteInstallCancellation.finish(operationId, signal)
|
||||
}
|
||||
}
|
||||
ipcMain.handle('skills:resolveShare', (_event, shareId: unknown) =>
|
||||
runtime.resolveSkillShare(z.string().min(1).max(128).parse(shareId), {})
|
||||
)
|
||||
@@ -148,7 +163,7 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
}
|
||||
}
|
||||
ipcMain.handle('skills:installShare', async (event, value: unknown) => {
|
||||
const parsed = shareInstallSchema.parse(value)
|
||||
const parsed = skillCloudShareInstallSchema.parse(value)
|
||||
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
|
||||
sendProgress(event, { operationId: input.operationId, phase: 'authorizing' })
|
||||
if (
|
||||
@@ -167,8 +182,29 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
}
|
||||
return grant.status === 'ok' ? installAuthorizedGrant(grant.value, input) : grant
|
||||
})
|
||||
ipcMain.handle('skills:installBundleShare', async (event, value: unknown) => {
|
||||
const parsed = skillCloudBundleShareInstallSchema.parse(value)
|
||||
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
|
||||
sendProgress(event, { operationId: input.operationId, phase: 'authorizing' })
|
||||
if (
|
||||
input.environmentId &&
|
||||
!input.environmentId.startsWith('ssh:') &&
|
||||
!(await supportsSkillRuntimeBundleInstall(app.getPath('userData'), input.environmentId))
|
||||
) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const installTarget = await classifySkillCloudInstallTarget(runtime, input)
|
||||
const grant = await runtime.createSkillDownloadGrant(input.shareId, {
|
||||
versionId: input.versionId,
|
||||
installTarget
|
||||
})
|
||||
if (grant.status === 'ok') {
|
||||
sendProgress(event, { operationId: input.operationId, phase: 'installing' })
|
||||
}
|
||||
return grant.status === 'ok' ? installAuthorizedBundleGrant(grant.value, input) : grant
|
||||
})
|
||||
ipcMain.handle('skills:installPackageVersion', async (event, value: unknown) => {
|
||||
const parsed = packageVersionInstallSchema.parse(value)
|
||||
const parsed = skillCloudPackageVersionInstallSchema.parse(value)
|
||||
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
|
||||
sendProgress(event, { operationId: input.operationId, phase: 'authorizing' })
|
||||
if (
|
||||
@@ -188,11 +224,33 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
}
|
||||
return grant.status === 'ok' ? installAuthorizedGrant(grant.value, input) : grant
|
||||
})
|
||||
ipcMain.handle('skills:installBundlePackageVersion', async (event, value: unknown) => {
|
||||
const parsed = skillCloudBundlePackageVersionInstallSchema.parse(value)
|
||||
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
|
||||
sendProgress(event, { operationId: input.operationId, phase: 'authorizing' })
|
||||
if (
|
||||
input.environmentId &&
|
||||
!input.environmentId.startsWith('ssh:') &&
|
||||
!(await supportsSkillRuntimeBundleInstall(app.getPath('userData'), input.environmentId))
|
||||
) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const installTarget = await classifySkillCloudInstallTarget(runtime, input)
|
||||
const grant = await runtime.createSkillPackageVersionDownloadGrant(
|
||||
input.packageId,
|
||||
input.versionId,
|
||||
{ installTarget }
|
||||
)
|
||||
if (grant.status === 'ok') {
|
||||
sendProgress(event, { operationId: input.operationId, phase: 'installing' })
|
||||
}
|
||||
return grant.status === 'ok' ? installAuthorizedBundleGrant(grant.value, input) : grant
|
||||
})
|
||||
ipcMain.handle('skills:cancelInstall', async (_event, value: unknown) => {
|
||||
const input = z
|
||||
.object({
|
||||
operationId: z.string().min(1).max(128),
|
||||
environmentId: environmentIdSchema.optional()
|
||||
environmentId: skillCloudInstallEnvironmentIdSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.parse(value)
|
||||
|
||||
@@ -2,6 +2,10 @@ import { randomUUID } from 'node:crypto'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability'
|
||||
import {
|
||||
SkillBundleInstallPreviewRequestSchema,
|
||||
SkillBundleInstallPreviewSchema
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
ManagedSkillInstallListSchema,
|
||||
SkillInstallDestinationSchema,
|
||||
@@ -10,7 +14,10 @@ import {
|
||||
SkillPackageIdentitySchema
|
||||
} from '../../shared/skill-install-contract'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { supportsSkillRuntimeManagement } from '../skills/skill-runtime-capability'
|
||||
import {
|
||||
supportsSkillRuntimeBundleInstall,
|
||||
supportsSkillRuntimeManagement
|
||||
} from '../skills/skill-runtime-capability'
|
||||
import { listWslDistrosAsync } from '../wsl'
|
||||
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
|
||||
|
||||
@@ -33,6 +40,52 @@ const removeSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
type BundlePreviewInput = z.infer<typeof SkillBundleInstallPreviewRequestSchema> & {
|
||||
environmentId?: string
|
||||
}
|
||||
|
||||
async function previewBundleInstall(runtime: OrcaRuntimeService, input: BundlePreviewInput) {
|
||||
const previews = await Promise.all(
|
||||
input.selectedSkills.map(async (skill) => {
|
||||
const request = {
|
||||
package: {
|
||||
packageId: input.package.packageId,
|
||||
versionId: input.package.versionId,
|
||||
packageDigest: skill.digest,
|
||||
archiveSha256: input.package.archiveSha256,
|
||||
compressedBytes: input.package.compressedBytes
|
||||
},
|
||||
name: skill.name,
|
||||
destination: input.destination
|
||||
}
|
||||
if (!input.environmentId) {
|
||||
return runtime.previewSharedSkillInstallRequest(request)
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
app.getPath('userData'),
|
||||
input.environmentId,
|
||||
'skills.previewInstall',
|
||||
request,
|
||||
30_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-bundle-preview-remote-${response.error.code}`)
|
||||
}
|
||||
return SkillInstallPreviewSchema.parse(response.result)
|
||||
})
|
||||
)
|
||||
return SkillBundleInstallPreviewSchema.parse({
|
||||
packageId: input.package.packageId,
|
||||
versionId: input.package.versionId,
|
||||
bundleDigest: input.package.bundleDigest,
|
||||
destinationIdentity: previews[0]?.destinationIdentity ?? '',
|
||||
skills: input.selectedSkills.map((skill, index) => ({
|
||||
...skill,
|
||||
currentState: previews[index].currentState
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export function registerSkillInstallManagementIpcHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.handle('skills:listWslDistros', async (_event, environmentIdValue: unknown) => {
|
||||
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
|
||||
@@ -75,6 +128,25 @@ export function registerSkillInstallManagementIpcHandlers(runtime: OrcaRuntimeSe
|
||||
}
|
||||
return { status: 'ok' as const, value: SkillInstallPreviewSchema.parse(response.result) }
|
||||
})
|
||||
ipcMain.handle('skills:previewBundleInstall', async (_event, value: unknown) => {
|
||||
const parsed = z
|
||||
.object({
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
package: SkillBundleInstallPreviewRequestSchema.shape.package,
|
||||
selectedSkills: SkillBundleInstallPreviewRequestSchema.shape.selectedSkills,
|
||||
destination: SkillInstallDestinationSchema
|
||||
})
|
||||
.strict()
|
||||
.parse(value)
|
||||
if (
|
||||
parsed.environmentId &&
|
||||
(!(await supportsSkillRuntimeManagement(app.getPath('userData'), parsed.environmentId)) ||
|
||||
!(await supportsSkillRuntimeBundleInstall(app.getPath('userData'), parsed.environmentId)))
|
||||
) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
return { status: 'ok' as const, value: await previewBundleInstall(runtime, parsed) }
|
||||
})
|
||||
ipcMain.handle('skills:removeInstall', async (_event, value: unknown) => {
|
||||
const input = removeSchema.parse(value)
|
||||
const request = {
|
||||
|
||||
@@ -152,7 +152,12 @@ import type {
|
||||
ManagedSkillInstall,
|
||||
SkillRemoveRequest
|
||||
} from '../../shared/skill-install-contract'
|
||||
import type {
|
||||
SkillBundleInstallRequest,
|
||||
SkillBundleInstallResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import { executeSkillInstallRequest } from '../skills/skill-install-request-service'
|
||||
import { executeSkillBundleInstallRequest } from '../skills/skill-bundle-install-request-service'
|
||||
import type { SkillInstallDestinationAuthority } from '../skills/skill-install-destinations'
|
||||
import {
|
||||
previewSharedSkillInstall,
|
||||
@@ -168,6 +173,7 @@ import type {
|
||||
} from '../../shared/skill-upload-session-contract'
|
||||
import { SkillUploadSessionService } from '../skills/skill-upload-session-service'
|
||||
import type { SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract'
|
||||
import { installSkillBundleOnSshHost } from '../skills/skill-bundle-ssh-relay-service'
|
||||
import {
|
||||
installSkillOnSshHost,
|
||||
listSkillInstallsOnSshHost,
|
||||
@@ -4581,6 +4587,24 @@ export class OrcaRuntimeService {
|
||||
return this.requireSkillCloudService().publish(request)
|
||||
}
|
||||
|
||||
publishSkillPackageVersion(
|
||||
request: SkillCloudPublishRequest
|
||||
): Promise<SkillCloudOperation<SkillCloudVersion>> {
|
||||
return this.requireSkillCloudService().publishVersion(request)
|
||||
}
|
||||
|
||||
createSkillPackageShare(
|
||||
packageId: string,
|
||||
request: SkillCloudOptions & {
|
||||
pinnedVersionId?: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
idempotencyKey?: string
|
||||
}
|
||||
) {
|
||||
return this.requireSkillCloudService().createShare(packageId, request)
|
||||
}
|
||||
|
||||
resolveSkillShare(
|
||||
shareId: string,
|
||||
options: SkillCloudOptions
|
||||
@@ -4672,6 +4696,66 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async installSharedSkillBundleRequest(
|
||||
request: SkillBundleInstallRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<SkillBundleInstallResult> {
|
||||
if (this.skillInstallOperations.has(request.operationId)) {
|
||||
throw new Error('skill-install-operation-in-progress')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => controller.abort()
|
||||
if (signal?.aborted) {
|
||||
abort()
|
||||
} else {
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
this.skillInstallOperations.set(request.operationId, controller)
|
||||
try {
|
||||
const runtimeId = this.getStatus().runtimeId
|
||||
const sshTarget = await this.resolveSkillSshTarget(request.destination)
|
||||
if (sshTarget) {
|
||||
return installSkillBundleOnSshHost({
|
||||
provider: sshTarget.provider,
|
||||
userDataPath: app.getPath('userData'),
|
||||
request: {
|
||||
...request,
|
||||
destination:
|
||||
request.destination.scope === 'global'
|
||||
? { scope: 'global', executionTarget: { kind: 'host' } }
|
||||
: request.destination
|
||||
},
|
||||
workspace: sshTarget.workspace,
|
||||
requireHttps: app.isPackaged,
|
||||
signal: controller.signal
|
||||
})
|
||||
}
|
||||
const allowedDownloadOrigins = ['https://storage.googleapis.com']
|
||||
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
|
||||
allowedDownloadOrigins.push(
|
||||
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
return await executeSkillBundleInstallRequest(request, {
|
||||
authority: this.skillInstallDestinationAuthority(runtimeId),
|
||||
stateDirectory: app.getPath('userData'),
|
||||
allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)],
|
||||
requireHttps: app.isPackaged,
|
||||
resolveStagedUpload: (uploadId, identity) =>
|
||||
this.requireSkillUploadSessions().take(uploadId, identity),
|
||||
detectProviders: detectInstalledAgentsWithShellPathHydration,
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort)
|
||||
if (this.skillInstallOperations.get(request.operationId) === controller) {
|
||||
this.skillInstallOperations.delete(request.operationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelSharedSkillInstall(operationId: string): boolean {
|
||||
const operation = this.skillInstallOperations.get(operationId)
|
||||
operation?.abort()
|
||||
|
||||
@@ -87,6 +87,34 @@ describe('skills.discover RPC', () => {
|
||||
})
|
||||
|
||||
describe('skills.install RPC', () => {
|
||||
it('routes one bundle request without changing the single-skill method', async () => {
|
||||
const installSharedSkillBundleRequest = vi.fn(async () => ({ status: 'complete' }))
|
||||
const request = {
|
||||
operationId: 'operation_1',
|
||||
package: {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 100
|
||||
},
|
||||
selectedSkillIds: ['alpha'],
|
||||
ingress: {
|
||||
kind: 'download-grant' as const,
|
||||
url: 'https://storage.googleapis.com/package',
|
||||
expiresAt: '2026-08-11T12:00:00.000Z'
|
||||
},
|
||||
destination: { scope: 'global' as const },
|
||||
conflictDecisions: []
|
||||
}
|
||||
|
||||
await method('skills.installBundle').handler(request, {
|
||||
runtime: { installSharedSkillBundleRequest }
|
||||
} as unknown as RpcContext)
|
||||
|
||||
expect(installSharedSkillBundleRequest).toHaveBeenCalledWith(request, undefined)
|
||||
})
|
||||
|
||||
it('delegates installation to the executing runtime service', async () => {
|
||||
const installSharedSkillRequest = vi.fn(async () => ({ status: 'installed' }))
|
||||
const runtime = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SkillInstallRequestSchema,
|
||||
SkillRemoveRequestSchema
|
||||
} from '../../../../shared/skill-install-contract'
|
||||
import { SkillBundleInstallRequestSchema } from '../../../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
SkillUploadBeginRequestSchema,
|
||||
SkillUploadChunkRequestSchema,
|
||||
@@ -53,6 +54,12 @@ export const SKILL_METHODS: RpcMethod[] = [
|
||||
return result
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.installBundle',
|
||||
params: SkillBundleInstallRequestSchema,
|
||||
handler: (params, { runtime, signal }) =>
|
||||
runtime.installSharedSkillBundleRequest(params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.cancelInstall',
|
||||
params: z.object({ operationId: z.string().min(1).max(128) }).strict(),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AGENT_PLUGIN_SCHEMA_V1 } from '../../shared/skill-bundle-manifest'
|
||||
import { createSkillBundleArchive } from './skill-bundle-creation'
|
||||
import { extractSkillBundleArchive } from './skill-bundle-extraction'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-test-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function createSkill(root: string, name: string, description: string): Promise<string> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(
|
||||
join(directory, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`
|
||||
)
|
||||
await writeFile(join(directory, 'notes.txt'), `${name} notes\n`)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('skill bundle creation and extraction', () => {
|
||||
it('round trips multiple skills through an Agent Plugins compatible root', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const alpha = await createSkill(root, 'alpha-skill', 'Alpha')
|
||||
const beta = await createSkill(root, 'beta-skill', 'Beta')
|
||||
const created = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: beta }, { sourceDirectory: alpha }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills',
|
||||
description: 'Team skills',
|
||||
createdAt: '2026-08-11T12:00:00.000Z'
|
||||
})
|
||||
|
||||
const extracted = await extractSkillBundleArchive({
|
||||
archivePath: created.archivePath,
|
||||
destinationDirectory: join(root, 'extracted'),
|
||||
expectedBundleDigest: created.manifest.bundleDigest
|
||||
})
|
||||
|
||||
expect(extracted.pluginManifest).toEqual({
|
||||
$schema: AGENT_PLUGIN_SCHEMA_V1,
|
||||
name: 'team-skills',
|
||||
version: 'version_1',
|
||||
description: 'Team skills'
|
||||
})
|
||||
expect(extracted.manifest.skills.map((skill) => skill.name)).toEqual([
|
||||
'alpha-skill',
|
||||
'beta-skill'
|
||||
])
|
||||
expect(await readFile(join(extracted.skillsDirectory, 'beta-skill', 'notes.txt'), 'utf8')).toBe(
|
||||
'beta-skill notes\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates deterministic archives regardless of source selection order', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const alpha = await createSkill(root, 'alpha-skill', 'Alpha')
|
||||
const beta = await createSkill(root, 'beta-skill', 'Beta')
|
||||
const publication = {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills',
|
||||
createdAt: '2026-08-11T12:00:00.000Z'
|
||||
}
|
||||
const first = await createSkillBundleArchive({
|
||||
...publication,
|
||||
sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }],
|
||||
archivePath: join(root, 'first.tar.gz')
|
||||
})
|
||||
const second = await createSkillBundleArchive({
|
||||
...publication,
|
||||
sources: [{ sourceDirectory: beta }, { sourceDirectory: alpha }],
|
||||
archivePath: join(root, 'second.tar.gz')
|
||||
})
|
||||
|
||||
expect(first.archiveSha256).toBe(second.archiveSha256)
|
||||
expect(await readFile(first.archivePath)).toEqual(await readFile(second.archivePath))
|
||||
})
|
||||
|
||||
it('rejects duplicate names and source drift without publishing an archive', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const first = await createSkill(root, 'same-skill', 'First')
|
||||
const secondRoot = join(root, 'other')
|
||||
const second = await createSkill(secondRoot, 'same-skill', 'Second')
|
||||
const archivePath = join(root, 'duplicate.tar.gz')
|
||||
|
||||
await expect(
|
||||
createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: first }, { sourceDirectory: second }],
|
||||
archivePath,
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills'
|
||||
})
|
||||
).rejects.toThrow('skill-bundle-skill-collision')
|
||||
await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
AGENT_PLUGIN_MANIFEST_PATH,
|
||||
AGENT_PLUGIN_SCHEMA_V1,
|
||||
ORCA_SKILL_BUNDLE_MANIFEST_PATH,
|
||||
computeSkillBundleDigest,
|
||||
parseAgentPluginManifest,
|
||||
parseSkillBundleManifest,
|
||||
type AgentPluginManifestV1,
|
||||
type SkillBundleEntry,
|
||||
type SkillBundleManifestV1
|
||||
} from '../../shared/skill-bundle-manifest'
|
||||
import {
|
||||
computeSkillPackageDigest,
|
||||
validateSkillPackageName
|
||||
} from '../../shared/skill-package-manifest'
|
||||
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
|
||||
import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry'
|
||||
import { extractSkillBundleArchive } from './skill-bundle-extraction'
|
||||
import { observeSkillPackage, type ObservedSkillPackage } from './skill-package-identity'
|
||||
import { writeSkillTarGzip, type SkillTarWriteEntry } from './skill-package-tar'
|
||||
|
||||
export type SkillBundleSource = { id?: string; sourceDirectory: string }
|
||||
|
||||
export type CreatedSkillBundle = {
|
||||
pluginManifest: AgentPluginManifestV1
|
||||
manifest: SkillBundleManifestV1
|
||||
archivePath: string
|
||||
archiveSha256: string
|
||||
compressedBytes: number
|
||||
}
|
||||
|
||||
export type SkillBundleCreationDependencies = { afterSourcesObserved?: () => Promise<void> }
|
||||
|
||||
function observationsMatch(left: ObservedSkillPackage, right: ObservedSkillPackage): boolean {
|
||||
return (
|
||||
left.files.length === right.files.length &&
|
||||
left.files.every((file, index) => {
|
||||
const other = right.files[index]
|
||||
return (
|
||||
file.path === other.path &&
|
||||
file.size === other.size &&
|
||||
file.executable === other.executable &&
|
||||
file.classification === other.classification &&
|
||||
file.exactSha256 === other.exactSha256 &&
|
||||
file.identitySha256 === other.identitySha256
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function bundleEntry(input: {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
observed: ObservedSkillPackage
|
||||
}): SkillBundleEntry {
|
||||
const files = input.observed.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
executable: file.executable,
|
||||
classification: file.classification,
|
||||
sha256: file.exactSha256,
|
||||
identitySha256: file.identitySha256
|
||||
}))
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
digest: computeSkillPackageDigest(files),
|
||||
files
|
||||
}
|
||||
}
|
||||
|
||||
async function stageSkill(input: {
|
||||
source: SkillBundleSource
|
||||
sourceObservation: ObservedSkillPackage
|
||||
skillsRoot: string
|
||||
name: string
|
||||
description: string
|
||||
}): Promise<SkillBundleEntry> {
|
||||
const stagedDirectory = join(input.skillsRoot, input.name)
|
||||
await cp(input.source.sourceDirectory, stagedDirectory, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
force: false,
|
||||
errorOnExist: true
|
||||
})
|
||||
const stagedObservation = await observeSkillPackage(stagedDirectory)
|
||||
if (!observationsMatch(input.sourceObservation, stagedObservation)) {
|
||||
throw new Error('skill-package-source-changed-during-staging')
|
||||
}
|
||||
const stagedSummary = summarizeSkillMarkdown(
|
||||
await readFile(join(stagedDirectory, 'SKILL.md'), 'utf8')
|
||||
)
|
||||
if (
|
||||
stagedSummary.name !== input.name ||
|
||||
(stagedSummary.description ?? '') !== input.description
|
||||
) {
|
||||
throw new Error('skill-package-source-changed-during-staging')
|
||||
}
|
||||
return bundleEntry({
|
||||
id: input.source.id ?? input.name,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
observed: stagedObservation
|
||||
})
|
||||
}
|
||||
|
||||
export async function createSkillBundleArchive(
|
||||
input: {
|
||||
sources: readonly SkillBundleSource[]
|
||||
archivePath: string
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleName: string
|
||||
description?: string
|
||||
createdAt?: string
|
||||
},
|
||||
dependencies: SkillBundleCreationDependencies = {}
|
||||
): Promise<CreatedSkillBundle> {
|
||||
if (input.sources.length === 0) {
|
||||
throw new Error('skill-bundle-empty')
|
||||
}
|
||||
const sourceObservations = await Promise.all(
|
||||
input.sources.map((source) => observeSkillPackage(source.sourceDirectory))
|
||||
)
|
||||
const summaries = await Promise.all(
|
||||
input.sources.map(async (source) => {
|
||||
const summary = summarizeSkillMarkdown(
|
||||
await readFile(join(source.sourceDirectory, 'SKILL.md'), 'utf8')
|
||||
)
|
||||
if (!summary.name) {
|
||||
throw new Error('skill-package-skill-name-required')
|
||||
}
|
||||
validateSkillPackageName(summary.name)
|
||||
return { name: summary.name, description: summary.description ?? '' }
|
||||
})
|
||||
)
|
||||
const foldedNames = new Set<string>()
|
||||
for (const summary of summaries) {
|
||||
const foldedName = summary.name.toLocaleLowerCase('en-US')
|
||||
if (foldedNames.has(foldedName)) {
|
||||
throw new Error('skill-bundle-skill-collision')
|
||||
}
|
||||
foldedNames.add(foldedName)
|
||||
}
|
||||
await dependencies.afterSourcesObserved?.()
|
||||
const workDirectory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-'))
|
||||
const skillsRoot = join(workDirectory, 'skills')
|
||||
const verificationDirectory = join(workDirectory, 'verification')
|
||||
const temporaryArchive = `${input.archivePath}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await mkdir(skillsRoot, { recursive: true })
|
||||
const skills: SkillBundleEntry[] = []
|
||||
for (const [index, source] of input.sources.entries()) {
|
||||
const skill = await stageSkill({
|
||||
source,
|
||||
sourceObservation: sourceObservations[index],
|
||||
skillsRoot,
|
||||
...summaries[index]
|
||||
})
|
||||
skills.push(skill)
|
||||
}
|
||||
skills.sort((left, right) => left.name.localeCompare(right.name, 'en-US'))
|
||||
const manifest = parseSkillBundleManifest({
|
||||
schemaVersion: 1,
|
||||
packageId: input.packageId,
|
||||
versionId: input.versionId,
|
||||
bundleName: input.bundleName,
|
||||
description: input.description ?? '',
|
||||
createdAt: input.createdAt ?? new Date().toISOString(),
|
||||
skills,
|
||||
bundleDigest: computeSkillBundleDigest(skills)
|
||||
})
|
||||
const pluginManifest = parseAgentPluginManifest({
|
||||
$schema: AGENT_PLUGIN_SCHEMA_V1,
|
||||
name: manifest.bundleName,
|
||||
version: manifest.versionId,
|
||||
description: manifest.description
|
||||
})
|
||||
const pluginBytes = Buffer.from(JSON.stringify(pluginManifest), 'utf8')
|
||||
const manifestBytes = Buffer.from(JSON.stringify(manifest), 'utf8')
|
||||
const entries: SkillTarWriteEntry[] = [
|
||||
{
|
||||
path: AGENT_PLUGIN_MANIFEST_PATH,
|
||||
size: pluginBytes.length,
|
||||
executable: false,
|
||||
bytes: pluginBytes
|
||||
},
|
||||
{
|
||||
path: ORCA_SKILL_BUNDLE_MANIFEST_PATH,
|
||||
size: manifestBytes.length,
|
||||
executable: false,
|
||||
bytes: manifestBytes
|
||||
},
|
||||
...manifest.skills.flatMap((skill) =>
|
||||
skill.files.map((file) => ({
|
||||
path: `skills/${skill.name}/${file.path}`,
|
||||
size: file.size,
|
||||
executable: file.executable,
|
||||
sourcePath: join(skillsRoot, skill.name, ...file.path.split('/'))
|
||||
}))
|
||||
)
|
||||
]
|
||||
await mkdir(dirname(input.archivePath), { recursive: true })
|
||||
const archiveIdentity = await writeSkillTarGzip(temporaryArchive, entries)
|
||||
await extractSkillBundleArchive({
|
||||
archivePath: temporaryArchive,
|
||||
destinationDirectory: verificationDirectory,
|
||||
expectedArchiveSha256: archiveIdentity.archiveSha256,
|
||||
expectedBundleDigest: manifest.bundleDigest,
|
||||
expectedPackageId: manifest.packageId,
|
||||
expectedVersionId: manifest.versionId
|
||||
})
|
||||
await renameSkillPathWithWindowsRetry(temporaryArchive, input.archivePath)
|
||||
return { pluginManifest, manifest, archivePath: input.archivePath, ...archiveIdentity }
|
||||
} finally {
|
||||
await rm(workDirectory, { recursive: true, force: true })
|
||||
await rm(temporaryArchive, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rm } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
AGENT_PLUGIN_MANIFEST_PATH,
|
||||
ORCA_SKILL_BUNDLE_MANIFEST_PATH,
|
||||
parseAgentPluginManifest,
|
||||
parseSkillBundleManifest,
|
||||
type AgentPluginManifestV1,
|
||||
type SkillBundleManifestV1
|
||||
} from '../../shared/skill-bundle-manifest'
|
||||
import {
|
||||
SKILL_PACKAGE_MAX_MANIFEST_BYTES,
|
||||
type SkillPackageFile
|
||||
} from '../../shared/skill-package-manifest'
|
||||
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
|
||||
import { observeSkillPackage } from './skill-package-identity'
|
||||
import {
|
||||
openSkillTarGzip,
|
||||
parseSkillTarHeader,
|
||||
SKILL_TAR_BLOCK_BYTES,
|
||||
type TarByteReader
|
||||
} from './skill-package-tar'
|
||||
|
||||
export type SkillBundleExtractionResult = {
|
||||
pluginManifest: AgentPluginManifestV1
|
||||
manifest: SkillBundleManifestV1
|
||||
skillsDirectory: string
|
||||
archiveSha256: string
|
||||
compressedBytes: number
|
||||
}
|
||||
|
||||
async function consumePadding(reader: TarByteReader, size: number): Promise<void> {
|
||||
const padding = (SKILL_TAR_BLOCK_BYTES - (size % SKILL_TAR_BLOCK_BYTES)) % SKILL_TAR_BLOCK_BYTES
|
||||
if (padding > 0 && !(await reader.readExact(padding)).every((byte) => byte === 0)) {
|
||||
throw new Error('skill-package-tar-padding-invalid')
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonEntry(reader: TarByteReader, expectedPath: string): Promise<unknown> {
|
||||
const header = parseSkillTarHeader(await reader.readExact(SKILL_TAR_BLOCK_BYTES))
|
||||
if (
|
||||
!header ||
|
||||
header.path !== expectedPath ||
|
||||
header.executable ||
|
||||
header.size > SKILL_PACKAGE_MAX_MANIFEST_BYTES
|
||||
) {
|
||||
throw new Error('skill-bundle-manifest-envelope-invalid')
|
||||
}
|
||||
const bytes = await reader.readExact(header.size)
|
||||
await consumePadding(reader, header.size)
|
||||
try {
|
||||
return JSON.parse(bytes.toString('utf8'))
|
||||
} catch {
|
||||
throw new Error('skill-bundle-manifest-invalid')
|
||||
}
|
||||
}
|
||||
|
||||
async function extractFile(
|
||||
reader: TarByteReader,
|
||||
destination: string,
|
||||
expected: SkillPackageFile
|
||||
): Promise<void> {
|
||||
await mkdir(dirname(destination), { recursive: true, mode: 0o700 })
|
||||
const handle = await open(destination, 'wx', expected.executable ? 0o700 : 0o600)
|
||||
const hash = createHash('sha256')
|
||||
let offset = 0
|
||||
try {
|
||||
while (offset < expected.size) {
|
||||
const bytes = await reader.readExact(Math.min(64 * 1024, expected.size - offset))
|
||||
hash.update(bytes)
|
||||
let written = 0
|
||||
while (written < bytes.length) {
|
||||
const result = await handle.write(bytes, written, bytes.length - written, offset + written)
|
||||
if (result.bytesWritten === 0) {
|
||||
throw new Error('skill-package-extraction-write-failed')
|
||||
}
|
||||
written += result.bytesWritten
|
||||
}
|
||||
offset += bytes.length
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (hash.digest('hex') !== expected.sha256) {
|
||||
throw new Error('skill-package-file-digest-mismatch')
|
||||
}
|
||||
await consumePadding(reader, expected.size)
|
||||
}
|
||||
|
||||
async function requireArchiveEnd(reader: TarByteReader): Promise<void> {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
if (!(await reader.readExact(SKILL_TAR_BLOCK_BYTES)).every((byte) => byte === 0)) {
|
||||
throw new Error('skill-package-tar-trailing-entry')
|
||||
}
|
||||
}
|
||||
for (;;) {
|
||||
const block = await reader.readExactOrNull(SKILL_TAR_BLOCK_BYTES)
|
||||
if (!block) {
|
||||
return
|
||||
}
|
||||
if (!block.every((byte) => byte === 0)) {
|
||||
throw new Error('skill-package-tar-trailing-data')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySkill(input: {
|
||||
directory: string
|
||||
manifest: SkillBundleManifestV1['skills'][number]
|
||||
}): Promise<void> {
|
||||
const observed = await observeSkillPackage(input.directory)
|
||||
if (
|
||||
observed.observedDigest !== input.manifest.digest ||
|
||||
observed.files.length !== input.manifest.files.length ||
|
||||
observed.files.some((file, index) => {
|
||||
const expected = input.manifest.files[index]
|
||||
return (
|
||||
file.path !== expected.path ||
|
||||
file.exactSha256 !== expected.sha256 ||
|
||||
file.identitySha256 !== expected.identitySha256 ||
|
||||
file.executable !== expected.executable
|
||||
)
|
||||
})
|
||||
) {
|
||||
throw new Error('skill-bundle-extracted-identity-mismatch')
|
||||
}
|
||||
const summary = summarizeSkillMarkdown(await readFile(join(input.directory, 'SKILL.md'), 'utf8'))
|
||||
if (summary.name !== input.manifest.name) {
|
||||
throw new Error('skill-package-skill-name-mismatch')
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractSkillBundleArchive(input: {
|
||||
archivePath: string
|
||||
destinationDirectory: string
|
||||
expectedArchiveSha256?: string
|
||||
expectedBundleDigest?: string
|
||||
expectedPackageId?: string
|
||||
expectedVersionId?: string
|
||||
}): Promise<SkillBundleExtractionResult> {
|
||||
const archive = await openSkillTarGzip(input.archivePath)
|
||||
let destinationCreated = false
|
||||
try {
|
||||
const pluginManifest = parseAgentPluginManifest(
|
||||
await readJsonEntry(archive.reader, AGENT_PLUGIN_MANIFEST_PATH)
|
||||
)
|
||||
const manifest = parseSkillBundleManifest(
|
||||
await readJsonEntry(archive.reader, ORCA_SKILL_BUNDLE_MANIFEST_PATH)
|
||||
)
|
||||
if (
|
||||
pluginManifest.name !== manifest.bundleName ||
|
||||
pluginManifest.version !== manifest.versionId ||
|
||||
(input.expectedBundleDigest && input.expectedBundleDigest !== manifest.bundleDigest) ||
|
||||
(input.expectedPackageId && input.expectedPackageId !== manifest.packageId) ||
|
||||
(input.expectedVersionId && input.expectedVersionId !== manifest.versionId)
|
||||
) {
|
||||
throw new Error('skill-bundle-identity-mismatch')
|
||||
}
|
||||
const skillsDirectory = join(input.destinationDirectory, 'skills')
|
||||
await mkdir(skillsDirectory, { recursive: true, mode: 0o700 })
|
||||
destinationCreated = true
|
||||
for (const skill of manifest.skills) {
|
||||
for (const file of skill.files) {
|
||||
const archivePath = `skills/${skill.name}/${file.path}`
|
||||
const header = parseSkillTarHeader(await archive.reader.readExact(SKILL_TAR_BLOCK_BYTES))
|
||||
if (
|
||||
!header ||
|
||||
header.path !== archivePath ||
|
||||
header.size !== file.size ||
|
||||
header.executable !== file.executable
|
||||
) {
|
||||
throw new Error('skill-bundle-file-envelope-mismatch')
|
||||
}
|
||||
await extractFile(
|
||||
archive.reader,
|
||||
join(skillsDirectory, skill.name, ...file.path.split('/')),
|
||||
file
|
||||
)
|
||||
}
|
||||
}
|
||||
await requireArchiveEnd(archive.reader)
|
||||
const archiveIdentity = await archive.archiveIdentity
|
||||
if (
|
||||
input.expectedArchiveSha256 &&
|
||||
archiveIdentity.archiveSha256 !== input.expectedArchiveSha256
|
||||
) {
|
||||
throw new Error('skill-package-archive-digest-mismatch')
|
||||
}
|
||||
await Promise.all(
|
||||
manifest.skills.map((skill) =>
|
||||
verifySkill({ directory: join(skillsDirectory, skill.name), manifest: skill })
|
||||
)
|
||||
)
|
||||
return { pluginManifest, manifest, skillsDirectory, ...archiveIdentity }
|
||||
} catch (error) {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
archive.abort(failure)
|
||||
await archive.archiveIdentity.catch(() => undefined)
|
||||
if (destinationCreated) {
|
||||
await rm(input.destinationDirectory, { recursive: true, force: true })
|
||||
}
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import {
|
||||
SkillBundleInstallRequestSchema,
|
||||
type SkillBundleInstallRequest,
|
||||
type SkillBundleInstallResult,
|
||||
type SkillBundlePackageIdentity
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import { resolveSkillInstallDestination } from './skill-install-destinations'
|
||||
import type { SkillInstallDestinationAuthority } from './skill-install-destinations'
|
||||
import { installSkillBundle } from './skill-bundle-install-service'
|
||||
import { downloadSkillPackageGrant } from './skill-package-download'
|
||||
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
|
||||
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
|
||||
|
||||
type StagedSkillBundle = { archivePath: string; cleanup(): Promise<void> }
|
||||
|
||||
export type SkillBundleInstallRequestDependencies = {
|
||||
authority: SkillInstallDestinationAuthority
|
||||
stateDirectory: string
|
||||
allowedDownloadOrigins: readonly string[]
|
||||
requireHttps: boolean
|
||||
allowTrustedLocalFile?: boolean
|
||||
signal?: AbortSignal
|
||||
fetcher?: typeof fetch
|
||||
detectProviders: () => Promise<readonly string[]>
|
||||
resolveStagedUpload?: (
|
||||
uploadId: string,
|
||||
identity: SkillBundlePackageIdentity
|
||||
) => Promise<StagedSkillBundle>
|
||||
}
|
||||
|
||||
async function resolveIngress(
|
||||
request: SkillBundleInstallRequest,
|
||||
dependencies: SkillBundleInstallRequestDependencies
|
||||
): Promise<StagedSkillBundle> {
|
||||
if (request.ingress.kind === 'download-grant') {
|
||||
return downloadSkillPackageGrant({
|
||||
url: request.ingress.url,
|
||||
expiresAt: request.ingress.expiresAt,
|
||||
expectedArchiveSha256: request.package.archiveSha256,
|
||||
expectedCompressedBytes: request.package.compressedBytes,
|
||||
temporaryRoot: join(dependencies.stateDirectory, 'skill-installs', 'downloads'),
|
||||
allowedOrigins: dependencies.allowedDownloadOrigins,
|
||||
requireHttps: dependencies.requireHttps,
|
||||
signal: dependencies.signal,
|
||||
fetcher: dependencies.fetcher
|
||||
})
|
||||
}
|
||||
if (request.ingress.kind === 'staged-upload') {
|
||||
if (!dependencies.resolveStagedUpload) {
|
||||
throw new Error('skill-bundle-staged-upload-unsupported')
|
||||
}
|
||||
return dependencies.resolveStagedUpload(request.ingress.uploadId, request.package)
|
||||
}
|
||||
if (!dependencies.allowTrustedLocalFile || !isAbsolute(request.ingress.path)) {
|
||||
throw new Error('skill-install-local-ingress-rejected')
|
||||
}
|
||||
return { archivePath: request.ingress.path, cleanup: async () => undefined }
|
||||
}
|
||||
|
||||
export async function executeSkillBundleInstallRequest(
|
||||
input: unknown,
|
||||
dependencies: SkillBundleInstallRequestDependencies
|
||||
): Promise<SkillBundleInstallResult> {
|
||||
const request = SkillBundleInstallRequestSchema.parse(input)
|
||||
const destination = await resolveSkillInstallDestination(
|
||||
request.destination,
|
||||
dependencies.authority
|
||||
)
|
||||
const ingress = await resolveIngress(request, dependencies)
|
||||
try {
|
||||
const detectedProviders = destination.wslDistro
|
||||
? await detectSkillProvidersInWsl(destination.wslDistro)
|
||||
: await dependencies.detectProviders()
|
||||
const filesystem = destination.wslDistro
|
||||
? createWslSkillInstallFilesystem({
|
||||
distro: destination.wslDistro,
|
||||
homeDirectory: destination.homeDirectory,
|
||||
workspaceDirectory: destination.workspaceDirectory
|
||||
})
|
||||
: undefined
|
||||
return await installSkillBundle({
|
||||
operationId: request.operationId,
|
||||
archivePath: ingress.archivePath,
|
||||
packageId: request.package.packageId,
|
||||
versionId: request.package.versionId,
|
||||
bundleDigest: request.package.bundleDigest,
|
||||
selectedSkillIds: request.selectedSkillIds,
|
||||
conflictDecisions: new Map(
|
||||
request.conflictDecisions.map((decision) => [decision.skillId, decision.resolution])
|
||||
),
|
||||
scope: destination.scope,
|
||||
homeDirectory: destination.homeDirectory,
|
||||
workspaceDirectory: destination.workspaceDirectory,
|
||||
orcaStateDirectory: dependencies.stateDirectory,
|
||||
detectedProviders,
|
||||
destinationIdentity: destination.destinationIdentity,
|
||||
hostIdentity: dependencies.authority.environmentId,
|
||||
expectedArchiveSha256: request.package.archiveSha256,
|
||||
filesystem,
|
||||
wslDistro: destination.wslDistro,
|
||||
signal: dependencies.signal
|
||||
})
|
||||
} finally {
|
||||
await ingress.cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createSkillBundleArchive } from './skill-bundle-creation'
|
||||
import { installSkillBundle } from './skill-bundle-install-service'
|
||||
import { listManagedSkillInstalls } from './skill-install-provenance'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-install-test-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function createSkill(root: string, name: string): Promise<string> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(
|
||||
join(directory, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: ${name}\n---\n\n# ${name}\n`
|
||||
)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('skill bundle installation', () => {
|
||||
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')
|
||||
const beta = await createSkill(join(root, 'sources'), 'beta-skill')
|
||||
const homeDirectory = join(root, 'home')
|
||||
const existingBeta = join(homeDirectory, '.agents', 'skills', 'beta-skill')
|
||||
await mkdir(existingBeta, { recursive: true })
|
||||
await writeFile(
|
||||
join(existingBeta, 'SKILL.md'),
|
||||
'---\nname: beta-skill\ndescription: Local\n---\n\n# Keep me\n'
|
||||
)
|
||||
const bundle = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills',
|
||||
createdAt: '2026-08-11T12:00:00.000Z'
|
||||
})
|
||||
|
||||
const result = await installSkillBundle({
|
||||
operationId: 'operation_1',
|
||||
archivePath: bundle.archivePath,
|
||||
packageId: bundle.manifest.packageId,
|
||||
versionId: bundle.manifest.versionId,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
selectedSkillIds: ['alpha-skill', 'beta-skill'],
|
||||
expectedArchiveSha256: bundle.archiveSha256,
|
||||
scope: 'global',
|
||||
homeDirectory,
|
||||
orcaStateDirectory: join(root, 'state'),
|
||||
detectedProviders: [],
|
||||
destinationIdentity: 'local-global',
|
||||
hostIdentity: 'host_1'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('partial')
|
||||
expect(result.skills.map((skill) => [skill.name, skill.status])).toEqual([
|
||||
['alpha-skill', 'installed'],
|
||||
['beta-skill', 'kept-local']
|
||||
])
|
||||
expect(
|
||||
await readFile(join(homeDirectory, '.agents', 'skills', 'alpha-skill', 'SKILL.md'), 'utf8')
|
||||
).toContain('# alpha-skill')
|
||||
expect(await readFile(join(existingBeta, 'SKILL.md'), 'utf8')).toContain('# Keep me')
|
||||
await expect(listManagedSkillInstalls(join(root, 'state', 'skill-installs'))).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alpha-skill',
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: bundle.manifest.bundleDigest
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
SkillBundleInstallResult,
|
||||
SkillBundleSkillResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import { SkillBundleInstallResultSchema } from '../../shared/skill-bundle-install-contract'
|
||||
import type { SkillBundleEntry } from '../../shared/skill-bundle-manifest'
|
||||
import { parseSkillPackageManifest } from '../../shared/skill-package-manifest'
|
||||
import { extractSkillBundleArchive } from './skill-bundle-extraction'
|
||||
import {
|
||||
installSharedExtractedSkill,
|
||||
skillInstallLocalInput,
|
||||
type SkillInstallServiceInput
|
||||
} from './skill-install-service'
|
||||
import {
|
||||
previewLocalExtractedSkillPackage,
|
||||
type LocalExtractedSkillPackage
|
||||
} from './skill-install-transaction'
|
||||
import { skillInstallFailureFromError } from './skill-install-operation-error'
|
||||
import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry'
|
||||
|
||||
export type SkillBundleInstallServiceInput = Omit<
|
||||
SkillInstallServiceInput,
|
||||
'expectedPackageDigest' | 'expectedPackageId' | 'expectedVersionId' | 'conflictResolution'
|
||||
> & {
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleDigest: string
|
||||
selectedSkillIds: readonly string[]
|
||||
conflictDecisions?: ReadonlyMap<
|
||||
string,
|
||||
'keep-local' | 'replace-unmodified' | 'replace-and-discard-local'
|
||||
>
|
||||
}
|
||||
|
||||
function skillManifest(input: {
|
||||
packageId: string
|
||||
versionId: string
|
||||
createdAt: string
|
||||
skill: SkillBundleEntry
|
||||
}) {
|
||||
return parseSkillPackageManifest({
|
||||
schemaVersion: 1,
|
||||
packageId: input.packageId,
|
||||
versionId: input.versionId,
|
||||
name: input.skill.name,
|
||||
description: input.skill.description,
|
||||
createdAt: input.createdAt,
|
||||
files: input.skill.files,
|
||||
packageDigest: input.skill.digest
|
||||
})
|
||||
}
|
||||
|
||||
function conflictResult(
|
||||
skill: SkillBundleEntry,
|
||||
preview: Awaited<ReturnType<typeof previewLocalExtractedSkillPackage>>
|
||||
): SkillBundleSkillResult {
|
||||
const state = preview.currentState
|
||||
const kind =
|
||||
state.kind === 'modified' ||
|
||||
state.kind === 'unowned' ||
|
||||
state.kind === 'external-link' ||
|
||||
state.kind === 'name-collision'
|
||||
? state.kind
|
||||
: 'modified'
|
||||
return {
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
digest: skill.digest,
|
||||
status: 'kept-local',
|
||||
placements: [],
|
||||
conflict: {
|
||||
kind,
|
||||
...('digest' in state && state.digest ? { existingDigest: state.digest } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function completedSkillResult(input: {
|
||||
skill: SkillBundleEntry
|
||||
preview: Awaited<ReturnType<typeof previewLocalExtractedSkillPackage>>
|
||||
result: Awaited<ReturnType<typeof installSharedExtractedSkill>>
|
||||
}): SkillBundleSkillResult {
|
||||
const status =
|
||||
input.result.status === 'partial'
|
||||
? input.preview.currentState.kind === 'missing'
|
||||
? 'installed'
|
||||
: input.preview.currentState.kind === 'unchanged'
|
||||
? 'unchanged'
|
||||
: 'updated'
|
||||
: input.result.status === 'conflict'
|
||||
? 'kept-local'
|
||||
: input.result.status === 'cancelled'
|
||||
? 'cancelled'
|
||||
: input.result.status === 'failed'
|
||||
? 'failed'
|
||||
: input.result.status === 'removed'
|
||||
? 'failed'
|
||||
: input.result.status
|
||||
return {
|
||||
skillId: input.skill.id,
|
||||
name: input.skill.name,
|
||||
digest: input.skill.digest,
|
||||
status,
|
||||
...(input.result.canonicalPath ? { canonicalPath: input.result.canonicalPath } : {}),
|
||||
placements: input.result.placements
|
||||
.filter((placement) => placement.status !== 'removed')
|
||||
.map((placement) => ({
|
||||
...placement,
|
||||
status: placement.status === 'removed' ? 'skipped' : placement.status
|
||||
})),
|
||||
...(input.result.conflict ? { conflict: input.result.conflict } : {}),
|
||||
...(input.result.errorCategory ? { errorCategory: input.result.errorCategory } : {}),
|
||||
...(input.result.failure ? { failure: input.result.failure } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function bundleStatus(
|
||||
skills: readonly SkillBundleSkillResult[]
|
||||
): SkillBundleInstallResult['status'] {
|
||||
if (skills.every((skill) => skill.status === 'cancelled')) {
|
||||
return 'cancelled'
|
||||
}
|
||||
if (skills.every((skill) => skill.status === 'failed')) {
|
||||
return 'failed'
|
||||
}
|
||||
return skills.some((skill) => ['kept-local', 'failed', 'cancelled'].includes(skill.status))
|
||||
? 'partial'
|
||||
: 'complete'
|
||||
}
|
||||
|
||||
export async function installSkillBundle(
|
||||
input: SkillBundleInstallServiceInput
|
||||
): Promise<SkillBundleInstallResult> {
|
||||
const destinationRoot = skillInstallLocalInput({
|
||||
...input,
|
||||
expectedPackageDigest: input.bundleDigest,
|
||||
expectedPackageId: input.packageId,
|
||||
expectedVersionId: input.versionId
|
||||
}).destinationRoot
|
||||
await mkdir(destinationRoot, { recursive: true })
|
||||
const extractionPath = join(destinationRoot, `.orca-skill-bundle-extract-${randomUUID()}`)
|
||||
const extracted = await extractSkillBundleArchive({
|
||||
archivePath: input.archivePath,
|
||||
destinationDirectory: extractionPath,
|
||||
expectedArchiveSha256: input.expectedArchiveSha256,
|
||||
expectedBundleDigest: input.bundleDigest,
|
||||
expectedPackageId: input.packageId,
|
||||
expectedVersionId: input.versionId
|
||||
})
|
||||
try {
|
||||
const selected = new Set(input.selectedSkillIds)
|
||||
const skills = extracted.manifest.skills.filter((skill) => selected.has(skill.id))
|
||||
if (skills.length !== selected.size) {
|
||||
throw new Error('skill-bundle-selection-invalid')
|
||||
}
|
||||
const results: SkillBundleSkillResult[] = []
|
||||
for (const skill of skills) {
|
||||
if (input.signal?.aborted) {
|
||||
results.push({
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
digest: skill.digest,
|
||||
status: 'cancelled',
|
||||
placements: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
const manifest = skillManifest({
|
||||
packageId: extracted.manifest.packageId,
|
||||
versionId: extracted.manifest.versionId,
|
||||
createdAt: extracted.manifest.createdAt,
|
||||
skill
|
||||
})
|
||||
const wrapper = join(extractionPath, 'selected', skill.id)
|
||||
const extractedSkill: LocalExtractedSkillPackage = {
|
||||
extractionPath: wrapper,
|
||||
manifest,
|
||||
archiveSha256: extracted.archiveSha256
|
||||
}
|
||||
const decision = input.conflictDecisions?.get(skill.id)
|
||||
const serviceInput: SkillInstallServiceInput = {
|
||||
...input,
|
||||
expectedPackageDigest: skill.digest,
|
||||
expectedPackageId: extracted.manifest.packageId,
|
||||
expectedVersionId: extracted.manifest.versionId,
|
||||
sourceBundleDigest: extracted.manifest.bundleDigest,
|
||||
conflictResolution: decision === 'keep-local' ? undefined : decision
|
||||
}
|
||||
const localInput = skillInstallLocalInput(serviceInput)
|
||||
const preview = await previewLocalExtractedSkillPackage(localInput, extractedSkill)
|
||||
const isConflict = !['missing', 'unchanged', 'clean-update'].includes(
|
||||
preview.currentState.kind
|
||||
)
|
||||
if (isConflict && decision !== 'replace-and-discard-local') {
|
||||
results.push(conflictResult(skill, preview))
|
||||
continue
|
||||
}
|
||||
await mkdir(wrapper, { recursive: true, mode: 0o700 })
|
||||
const source = join(extracted.skillsDirectory, skill.name)
|
||||
const target = join(wrapper, 'skill')
|
||||
await (input.filesystem
|
||||
? input.filesystem.rename(source, target)
|
||||
: renameSkillPathWithWindowsRetry(source, target))
|
||||
try {
|
||||
const result = await installSharedExtractedSkill(serviceInput, extractedSkill)
|
||||
results.push(completedSkillResult({ skill, preview, result }))
|
||||
} catch (error) {
|
||||
const failure = skillInstallFailureFromError(error)
|
||||
results.push({
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
digest: skill.digest,
|
||||
status: failure?.category === 'cancelled' ? 'cancelled' : 'failed',
|
||||
placements: [],
|
||||
...(failure ? { errorCategory: failure.code, failure } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
return SkillBundleInstallResultSchema.parse({
|
||||
operationId: input.operationId,
|
||||
packageId: extracted.manifest.packageId,
|
||||
versionId: extracted.manifest.versionId,
|
||||
bundleDigest: extracted.manifest.bundleDigest,
|
||||
status: bundleStatus(results),
|
||||
skills: results
|
||||
})
|
||||
} finally {
|
||||
await rm(extractionPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillBundleInstallRequest } from '../../shared/skill-bundle-install-contract'
|
||||
import { SKILL_PACKAGE_CONTENT_TYPE } from '../../shared/skill-package-manifest'
|
||||
import type { IPtyProvider } from '../providers/pty-provider-contract'
|
||||
import { installSkillBundleOnSshHost } from './skill-bundle-ssh-relay-service'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function userDataPath(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-bundle-ssh-client-test-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function request(bytes: Buffer): SkillBundleInstallRequest {
|
||||
return {
|
||||
operationId: 'bundle-operation',
|
||||
package: {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: 'a'.repeat(64),
|
||||
archiveSha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
compressedBytes: bytes.length
|
||||
},
|
||||
selectedSkillIds: ['skill-1'],
|
||||
ingress: {
|
||||
kind: 'download-grant',
|
||||
url: 'https://storage.googleapis.com/test/bundle.tar.gz',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString()
|
||||
},
|
||||
destination: { scope: 'global', executionTarget: { kind: 'host' } },
|
||||
conflictDecisions: []
|
||||
}
|
||||
}
|
||||
|
||||
function result() {
|
||||
return {
|
||||
operationId: 'bundle-operation',
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: 'a'.repeat(64),
|
||||
status: 'complete' as const,
|
||||
skills: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('installSkillBundleOnSshHost', () => {
|
||||
it('uses the additive method only when advertised by the SSH host', async () => {
|
||||
const bytes = Buffer.from('private bundle archive')
|
||||
const requestHostRpc = vi.fn(async (method: string) => {
|
||||
if (method === 'relay.status') {
|
||||
return { capabilities: ['skills.install.bundle.v1'] }
|
||||
}
|
||||
if (method === 'skills.installBundle') {
|
||||
return result()
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
installSkillBundleOnSshHost({
|
||||
provider: { requestHostRpc } as unknown as IPtyProvider,
|
||||
userDataPath: await userDataPath(),
|
||||
request: request(bytes),
|
||||
requireHttps: true
|
||||
})
|
||||
).resolves.toEqual(result())
|
||||
})
|
||||
|
||||
it('does not send an unknown method to an older SSH host', async () => {
|
||||
const requestHostRpc = vi.fn(async () => ({ capabilities: ['skills.install.v1'] }))
|
||||
|
||||
await expect(
|
||||
installSkillBundleOnSshHost({
|
||||
provider: { requestHostRpc } as unknown as IPtyProvider,
|
||||
userDataPath: await userDataPath(),
|
||||
request: request(Buffer.from('archive')),
|
||||
requireHttps: true
|
||||
})
|
||||
).rejects.toThrow('skill-bundle-ssh-update-required')
|
||||
expect(requestHostRpc).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back to client-mediated transfer after direct download fails', async () => {
|
||||
const bytes = Buffer.from('private bundle archive')
|
||||
const requestHostRpc = vi.fn(async (method: string, params: unknown) => {
|
||||
if (method === 'relay.status') {
|
||||
return { capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'] }
|
||||
}
|
||||
if (method === 'skills.installBundle') {
|
||||
const ingress = (params as { request: SkillBundleInstallRequest }).request.ingress
|
||||
if (ingress.kind === 'download-grant') {
|
||||
throw Object.assign(new Error('skill-download-transport-failed'), { code: -32000 })
|
||||
}
|
||||
return result()
|
||||
}
|
||||
if (method === 'skills.beginUpload') {
|
||||
return { uploadId: 'upload_1', chunkBytes: 256 * 1024 }
|
||||
}
|
||||
if (method === 'skills.uploadChunk') {
|
||||
const chunk = params as { offset: number; bytesBase64: string }
|
||||
return {
|
||||
acknowledgedOffset: chunk.offset + Buffer.from(chunk.bytesBase64, 'base64').length
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
await expect(
|
||||
installSkillBundleOnSshHost({
|
||||
provider: { requestHostRpc } as unknown as IPtyProvider,
|
||||
userDataPath: await userDataPath(),
|
||||
request: request(bytes),
|
||||
requireHttps: true,
|
||||
fetcher: vi.fn(
|
||||
async () =>
|
||||
new Response(bytes, { headers: { 'content-type': SKILL_PACKAGE_CONTENT_TYPE } })
|
||||
) as typeof fetch
|
||||
})
|
||||
).resolves.toEqual(result())
|
||||
expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([
|
||||
'relay.status',
|
||||
'skills.installBundle',
|
||||
'skills.beginUpload',
|
||||
'skills.uploadChunk',
|
||||
'skills.commitUpload',
|
||||
'skills.installBundle',
|
||||
'skills.cancelUpload'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
SkillBundleInstallResultSchema,
|
||||
type SkillBundleInstallRequest,
|
||||
type SkillBundleInstallResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_UPLOAD_CAPABILITY
|
||||
} from '../../shared/skill-install-capability'
|
||||
import {
|
||||
SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
|
||||
type SkillSshWorkspaceAuthority
|
||||
} from '../../shared/skill-ssh-relay-contract'
|
||||
import type { IPtyProvider } from '../providers/pty-provider-contract'
|
||||
import {
|
||||
SKILL_SSH_REQUEST_TIMEOUT_MS,
|
||||
requireSkillSshRelayClient,
|
||||
retryableSkillSshTransportError,
|
||||
shouldUseSkillSshClientTransfer,
|
||||
skillSshRelayCapabilities
|
||||
} from './skill-ssh-relay-client'
|
||||
import { transferSkillPackageToSshHost } from './skill-ssh-package-transfer'
|
||||
import { retrySkillTransferRpc } from './skill-transfer-rpc-retry'
|
||||
|
||||
export async function installSkillBundleOnSshHost(input: {
|
||||
provider: IPtyProvider
|
||||
userDataPath: string
|
||||
request: SkillBundleInstallRequest
|
||||
workspace?: SkillSshWorkspaceAuthority
|
||||
requireHttps: boolean
|
||||
signal?: AbortSignal
|
||||
fetcher?: typeof fetch
|
||||
}): Promise<SkillBundleInstallResult> {
|
||||
const client = requireSkillSshRelayClient(input.provider)
|
||||
const supported = await skillSshRelayCapabilities(client)
|
||||
if (!supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) {
|
||||
throw new Error('skill-bundle-ssh-update-required')
|
||||
}
|
||||
try {
|
||||
return SkillBundleInstallResultSchema.parse(
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
|
||||
{ request: input.request, workspace: input.workspace },
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
input.request.ingress.kind !== 'download-grant' ||
|
||||
!shouldUseSkillSshClientTransfer(error, input.requireHttps)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) {
|
||||
throw new Error('skill-bundle-ssh-download-unavailable')
|
||||
}
|
||||
return retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: async () => {
|
||||
const uploadId = await transferSkillPackageToSshHost(client, input)
|
||||
try {
|
||||
return SkillBundleInstallResultSchema.parse(
|
||||
await client(
|
||||
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
|
||||
{
|
||||
request: {
|
||||
...input.request,
|
||||
ingress: { kind: 'staged-upload', uploadId }
|
||||
},
|
||||
workspace: input.workspace
|
||||
},
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { SkillPackageIdentity } from '../../shared/skill-install-contract'
|
||||
import type { SkillBundlePackageIdentity } from '../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
SKILL_UPLOAD_CHUNK_MAX_BYTES,
|
||||
SkillUploadBeginResultSchema
|
||||
@@ -48,7 +49,7 @@ export async function transferSkillPackageToRuntime(input: {
|
||||
userDataPath: string
|
||||
environmentId: string
|
||||
transferId: string
|
||||
package: SkillPackageIdentity
|
||||
package: SkillPackageIdentity | SkillBundlePackageIdentity
|
||||
grant: { url: string; expiresAt: string }
|
||||
requireHttps: boolean
|
||||
signal?: AbortSignal
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getRuntimeEnvironmentStatus: vi.fn(),
|
||||
installSkillBundleOnRemoteRuntime: vi.fn(),
|
||||
installSkillOnRemoteRuntime: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -14,10 +15,14 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
getRuntimeEnvironmentStatus: mocks.getRuntimeEnvironmentStatus
|
||||
}))
|
||||
vi.mock('./skill-remote-install-service', () => ({
|
||||
installSkillBundleOnRemoteRuntime: mocks.installSkillBundleOnRemoteRuntime,
|
||||
installSkillOnRemoteRuntime: mocks.installSkillOnRemoteRuntime
|
||||
}))
|
||||
|
||||
import { installSkillCloudGrant } from './skill-cloud-grant-installation'
|
||||
import {
|
||||
installSkillBundleCloudGrant,
|
||||
installSkillCloudGrant
|
||||
} from './skill-cloud-grant-installation'
|
||||
|
||||
const grant = {
|
||||
grant: {
|
||||
@@ -37,6 +42,7 @@ const grant = {
|
||||
describe('installSkillCloudGrant', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getRuntimeEnvironmentStatus.mockReset()
|
||||
mocks.installSkillBundleOnRemoteRuntime.mockReset()
|
||||
mocks.installSkillOnRemoteRuntime.mockReset()
|
||||
})
|
||||
|
||||
@@ -71,4 +77,59 @@ describe('installSkillCloudGrant', () => {
|
||||
expect.objectContaining({ signal })
|
||||
)
|
||||
})
|
||||
|
||||
it('installs selected bundle skills through a capable remote runtime', async () => {
|
||||
const bundleGrant = {
|
||||
...grant,
|
||||
version: {
|
||||
...grant.version,
|
||||
name: 'private-bundle',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
bundleName: 'private-bundle',
|
||||
description: '',
|
||||
createdAt: '2026-08-11T00:00:00.000Z',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'private-skill',
|
||||
description: '',
|
||||
digest: 'a'.repeat(64),
|
||||
files: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
} as unknown as SkillCloudDownloadGrant
|
||||
const result = {
|
||||
operationId: 'operation-1',
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
status: 'complete',
|
||||
skills: []
|
||||
}
|
||||
mocks.getRuntimeEnvironmentStatus.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'] }
|
||||
})
|
||||
mocks.installSkillBundleOnRemoteRuntime.mockResolvedValue(result)
|
||||
|
||||
await expect(
|
||||
installSkillBundleCloudGrant({} as OrcaRuntimeService, bundleGrant, {
|
||||
operationId: 'operation-1',
|
||||
environmentId: 'environment-1',
|
||||
selectedSkillIds: ['skill-1'],
|
||||
destination: { scope: 'global' }
|
||||
})
|
||||
).resolves.toEqual({ status: 'ok', value: result })
|
||||
expect(mocks.installSkillBundleOnRemoteRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
request: expect.objectContaining({ selectedSkillIds: ['skill-1'] })
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE
|
||||
} from '../../shared/skill-install-capability'
|
||||
import type {
|
||||
SkillBundleInstallRequest,
|
||||
SkillBundleInstallResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import type {
|
||||
SkillInstallDestination,
|
||||
SkillInstallRequest
|
||||
@@ -11,7 +16,10 @@ import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract'
|
||||
import { getRuntimeEnvironmentStatus } from '../ipc/runtime-environment-transport-routing'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { skillInstallFailureFromError } from './skill-install-operation-error'
|
||||
import { installSkillOnRemoteRuntime } from './skill-remote-install-service'
|
||||
import {
|
||||
installSkillBundleOnRemoteRuntime,
|
||||
installSkillOnRemoteRuntime
|
||||
} from './skill-remote-install-service'
|
||||
|
||||
export type SkillCloudGrantInstallInput = {
|
||||
operationId: string
|
||||
@@ -20,6 +28,102 @@ export type SkillCloudGrantInstallInput = {
|
||||
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
|
||||
}
|
||||
|
||||
export type SkillBundleCloudGrantInstallInput = {
|
||||
operationId: string
|
||||
environmentId?: string
|
||||
selectedSkillIds: string[]
|
||||
destination: SkillInstallDestination
|
||||
conflictDecisions?: SkillBundleInstallRequest['conflictDecisions']
|
||||
}
|
||||
|
||||
function bundleFailureResult(
|
||||
grant: SkillCloudDownloadGrant,
|
||||
request: SkillBundleInstallRequest,
|
||||
failure: NonNullable<ReturnType<typeof skillInstallFailureFromError>>
|
||||
): SkillBundleInstallResult {
|
||||
const manifest = grant.version.manifest
|
||||
if (!('skills' in manifest)) {
|
||||
throw new Error('skill-bundle-cloud-manifest-required')
|
||||
}
|
||||
return {
|
||||
operationId: request.operationId,
|
||||
packageId: request.package.packageId,
|
||||
versionId: request.package.versionId,
|
||||
bundleDigest: request.package.bundleDigest,
|
||||
status: failure.category === 'cancelled' ? 'cancelled' : 'failed',
|
||||
skills: manifest.skills
|
||||
.filter((skill) => request.selectedSkillIds.includes(skill.id))
|
||||
.map((skill) => ({
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
digest: skill.digest,
|
||||
status: failure.category === 'cancelled' ? ('cancelled' as const) : ('failed' as const),
|
||||
placements: [],
|
||||
errorCategory: failure.code,
|
||||
failure
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSkillBundleCloudGrant(
|
||||
runtime: OrcaRuntimeService,
|
||||
grant: SkillCloudDownloadGrant,
|
||||
input: SkillBundleCloudGrantInstallInput,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const manifest = grant.version.manifest
|
||||
if (!('skills' in manifest)) {
|
||||
throw new Error('skill-bundle-cloud-manifest-required')
|
||||
}
|
||||
const request: SkillBundleInstallRequest = {
|
||||
operationId: input.operationId,
|
||||
package: {
|
||||
packageId: grant.version.packageId,
|
||||
versionId: grant.version.versionId,
|
||||
bundleDigest: manifest.bundleDigest,
|
||||
archiveSha256: grant.version.archiveSha256,
|
||||
compressedBytes: grant.version.compressedBytes
|
||||
},
|
||||
selectedSkillIds: input.selectedSkillIds,
|
||||
ingress: { kind: 'download-grant', ...grant.grant },
|
||||
destination: input.destination,
|
||||
conflictDecisions: input.conflictDecisions ?? []
|
||||
}
|
||||
try {
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await runtime.installSharedSkillBundleRequest(request)
|
||||
}
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
const status = await getRuntimeEnvironmentStatus(userDataPath, input.environmentId, 15_000)
|
||||
if (
|
||||
status.ok !== true ||
|
||||
status.result.capabilities?.includes(SKILL_BUNDLE_INSTALL_CAPABILITY) !== true
|
||||
) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await installSkillBundleOnRemoteRuntime({
|
||||
userDataPath,
|
||||
environmentId: input.environmentId,
|
||||
request,
|
||||
capabilities: status.result.capabilities ?? [],
|
||||
requireHttps: app.isPackaged,
|
||||
signal
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = skillInstallFailureFromError(error)
|
||||
if (!failure) {
|
||||
throw error
|
||||
}
|
||||
return { status: 'ok' as const, value: bundleFailureResult(grant, request, failure) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSkillCloudGrant(
|
||||
runtime: OrcaRuntimeService,
|
||||
grant: SkillCloudDownloadGrant,
|
||||
|
||||
@@ -26,9 +26,22 @@ function id(value: string): string {
|
||||
export class SkillCloudService {
|
||||
constructor(private readonly userDataPath: string) {}
|
||||
|
||||
publish(
|
||||
async publish(
|
||||
request: SkillCloudPublishRequest
|
||||
): Promise<SkillCloudOperation<SkillCloudPublishResult>> {
|
||||
const version = await this.publishVersion(request)
|
||||
if (version.status !== 'ok') {
|
||||
return version
|
||||
}
|
||||
const share = await this.createShare(version.value.packageId, request)
|
||||
return share.status === 'ok'
|
||||
? { status: 'ok', value: { version: version.value, share: share.value } }
|
||||
: share
|
||||
}
|
||||
|
||||
publishVersion(
|
||||
request: SkillCloudPublishRequest
|
||||
): Promise<SkillCloudOperation<SkillCloudVersion>> {
|
||||
return this.withAuth(request, async (token, apiUrl) => {
|
||||
const upload = await skillCloudRequest<{
|
||||
upload: {
|
||||
@@ -76,20 +89,33 @@ export class SkillCloudService {
|
||||
if (finalized.version.packageId !== request.packageId) {
|
||||
throw new Error('skill-cloud-published-package-mismatch')
|
||||
}
|
||||
return finalized.version
|
||||
})
|
||||
}
|
||||
|
||||
createShare(
|
||||
packageId: string,
|
||||
request: SkillCloudOptions & {
|
||||
pinnedVersionId?: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
idempotencyKey?: string
|
||||
}
|
||||
): Promise<SkillCloudOperation<SkillCloudShare>> {
|
||||
return this.withAuth(request, async (token, apiUrl) => {
|
||||
const shared = await skillCloudRequest<{ share: SkillCloudShare }>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/${id(request.packageId)}/shares`,
|
||||
path: `/v1/skill-packages/${id(packageId)}/shares`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
pinnedVersionId: request.pinnedVersionId,
|
||||
userIds: request.userIds,
|
||||
shareWithOrganization: request.shareWithOrganization
|
||||
},
|
||||
idempotencyKey: randomUUID(),
|
||||
signal: request.signal
|
||||
idempotencyKey: request.idempotencyKey ?? randomUUID()
|
||||
})
|
||||
return { version: finalized.version, share: shared.share }
|
||||
return shared.share
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type SkillInstallReceiptV1 = {
|
||||
packageId: string
|
||||
versionId: string
|
||||
packageDigest: string
|
||||
bundleDigest?: string
|
||||
archiveSha256: string
|
||||
scope: 'global' | 'workspace'
|
||||
destinationIdentity: string
|
||||
@@ -60,6 +61,7 @@ function isReceipt(value: unknown): value is SkillInstallReceiptV1 {
|
||||
typeof receipt.packageId === 'string' &&
|
||||
typeof receipt.versionId === 'string' &&
|
||||
typeof receipt.packageDigest === 'string' &&
|
||||
(receipt.bundleDigest === undefined || typeof receipt.bundleDigest === 'string') &&
|
||||
typeof receipt.archiveSha256 === 'string' &&
|
||||
(receipt.scope === 'global' || receipt.scope === 'workspace') &&
|
||||
typeof receipt.destinationIdentity === 'string' &&
|
||||
@@ -104,6 +106,7 @@ export async function listManagedSkillInstalls(
|
||||
packageId: parsed.packageId,
|
||||
versionId: parsed.versionId,
|
||||
packageDigest: parsed.packageDigest,
|
||||
...(parsed.bundleDigest ? { bundleDigest: parsed.bundleDigest } : {}),
|
||||
scope: parsed.scope,
|
||||
destinationIdentity: parsed.destinationIdentity,
|
||||
installedAt: parsed.installedAt,
|
||||
|
||||
@@ -2,9 +2,11 @@ import { join } from 'node:path'
|
||||
import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract'
|
||||
import { readSkillInstallReceipt, writeSkillInstallReceipt } from './skill-install-provenance'
|
||||
import {
|
||||
installLocalExtractedSkillPackage,
|
||||
installLocalSkillPackage,
|
||||
previewLocalSkillPackage,
|
||||
type LocalSkillInstallInput
|
||||
type LocalExtractedSkillPackage,
|
||||
type LocalSkillInstallInput,
|
||||
previewLocalSkillPackage
|
||||
} from './skill-install-transaction'
|
||||
import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
|
||||
import { resolveSkillProviderDestinations } from './skill-provider-destinations'
|
||||
@@ -12,6 +14,7 @@ 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'
|
||||
|
||||
export type SkillInstallServiceInput = Omit<
|
||||
LocalSkillInstallInput,
|
||||
@@ -52,7 +55,7 @@ function canonicalRoot(input: {
|
||||
return join(scopeRoot, '.agents', 'skills')
|
||||
}
|
||||
|
||||
function localInput(input: SkillInstallServiceInput): LocalSkillInstallInput {
|
||||
export function skillInstallLocalInput(input: SkillInstallServiceInput): LocalSkillInstallInput {
|
||||
return {
|
||||
operationId: input.operationId,
|
||||
archivePath: input.archivePath,
|
||||
@@ -65,6 +68,7 @@ function localInput(input: SkillInstallServiceInput): LocalSkillInstallInput {
|
||||
expectedPackageDigest: input.expectedPackageDigest,
|
||||
expectedPackageId: input.expectedPackageId,
|
||||
expectedVersionId: input.expectedVersionId,
|
||||
sourceBundleDigest: input.sourceBundleDigest,
|
||||
conflictResolution: input.conflictResolution,
|
||||
filesystem: input.filesystem,
|
||||
wslDistro: input.wslDistro,
|
||||
@@ -84,13 +88,47 @@ function canonicalPlacement(result: SkillInstallResult): SkillPlacementResult {
|
||||
export async function installSharedSkill(
|
||||
input: SkillInstallServiceInput
|
||||
): Promise<SkillInstallResult> {
|
||||
const request = localInput(input)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
export async function installSharedExtractedSkill(
|
||||
input: SkillInstallServiceInput,
|
||||
extracted: LocalExtractedSkillPackage
|
||||
): Promise<SkillInstallResult> {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
async function completeSharedSkillInstall(input: {
|
||||
input: SkillInstallServiceInput
|
||||
request: LocalSkillInstallInput
|
||||
manifest: SkillPackageManifestV1
|
||||
previousReceipt: Awaited<ReturnType<typeof readSkillInstallReceipt>>
|
||||
result: SkillInstallResult
|
||||
}): Promise<SkillInstallResult> {
|
||||
const { result } = input
|
||||
if (
|
||||
!result.canonicalPath ||
|
||||
result.status === 'conflict' ||
|
||||
@@ -101,16 +139,16 @@ export async function installSharedSkill(
|
||||
}
|
||||
const placements: SkillPlacementResult[] = [canonicalPlacement(result)]
|
||||
const destinations = resolveSkillProviderDestinations({
|
||||
scope: input.scope,
|
||||
homeDirectory: input.homeDirectory,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
detectedProviders: input.detectedProviders
|
||||
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.signal?.aborted) {
|
||||
if (input.input.signal?.aborted) {
|
||||
placements.push({
|
||||
provider: destination.provider,
|
||||
path: join(destination.rootPath, result.name),
|
||||
@@ -129,30 +167,30 @@ export async function installSharedSkill(
|
||||
canonicalPath: result.canonicalPath,
|
||||
skillName: result.name,
|
||||
destination,
|
||||
previousReceipt,
|
||||
previousReceipt: input.previousReceipt,
|
||||
packageDigest: result.packageDigest,
|
||||
fileModes: preview.manifest.files,
|
||||
filesystem: input.filesystem
|
||||
fileModes: input.manifest.files,
|
||||
filesystem: input.input.filesystem
|
||||
})
|
||||
if (placement) {
|
||||
placements.push(placement)
|
||||
}
|
||||
}
|
||||
const receipt = await readSkillInstallReceipt(request.stateDirectory, result.canonicalPath)
|
||||
const receipt = await readSkillInstallReceipt(input.request.stateDirectory, result.canonicalPath)
|
||||
if (!receipt) {
|
||||
throw new Error('skill-install-receipt-missing')
|
||||
}
|
||||
await writeSkillInstallReceipt(request.stateDirectory, { ...receipt, placements })
|
||||
await writeSkillInstallReceipt(input.request.stateDirectory, { ...receipt, placements })
|
||||
const incomplete = placements.some(
|
||||
(placement) => placement.status === 'failed' || placement.status === 'skipped'
|
||||
)
|
||||
return verifySkillInstallDiscovery({
|
||||
result: { ...result, status: incomplete ? 'partial' : result.status, placements },
|
||||
scope: input.scope,
|
||||
homeDirectory: input.homeDirectory,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
wslDistro: input.wslDistro,
|
||||
discover: input.discover
|
||||
scope: input.input.scope,
|
||||
homeDirectory: input.input.homeDirectory,
|
||||
workspaceDirectory: input.input.workspaceDirectory,
|
||||
wslDistro: input.input.wslDistro,
|
||||
discover: input.input.discover
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export function createSkillInstallReceipt(input: {
|
||||
packageId: input.manifest.packageId,
|
||||
versionId: input.manifest.versionId,
|
||||
packageDigest: input.manifest.packageDigest,
|
||||
...(input.request.sourceBundleDigest ? { bundleDigest: input.request.sourceBundleDigest } : {}),
|
||||
archiveSha256: input.archiveSha256,
|
||||
scope: input.request.scope,
|
||||
destinationIdentity: input.request.destinationIdentity,
|
||||
|
||||
@@ -48,6 +48,7 @@ export type LocalSkillInstallInput = {
|
||||
expectedPackageDigest?: string
|
||||
expectedPackageId?: string
|
||||
expectedVersionId?: string
|
||||
sourceBundleDigest?: string
|
||||
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
|
||||
filesystem?: SkillInstallFilesystem
|
||||
wslDistro?: string
|
||||
@@ -61,6 +62,12 @@ export type LocalSkillInstallPreview = {
|
||||
currentState: SkillCanonicalState
|
||||
}
|
||||
|
||||
export type LocalExtractedSkillPackage = {
|
||||
extractionPath: string
|
||||
manifest: SkillPackageManifestV1
|
||||
archiveSha256: string
|
||||
}
|
||||
|
||||
export type SkillInstallJournalBoundary = 'before' | 'after'
|
||||
|
||||
export type SkillInstallTransactionDependencies = {
|
||||
@@ -74,11 +81,9 @@ function lockPath(stateDirectory: string, canonicalPath: string): string {
|
||||
return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`)
|
||||
}
|
||||
|
||||
async function inspectExtractedPackage(input: LocalSkillInstallInput): Promise<{
|
||||
extractionPath: string
|
||||
manifest: SkillPackageManifestV1
|
||||
archiveSha256: string
|
||||
}> {
|
||||
async function inspectExtractedPackage(
|
||||
input: LocalSkillInstallInput
|
||||
): Promise<LocalExtractedSkillPackage> {
|
||||
await mkdir(input.destinationRoot, { recursive: true })
|
||||
const extractionPath = join(input.destinationRoot, `.orca-skill-extract-${randomUUID()}`)
|
||||
const extracted = await extractSkillPackageArchive({
|
||||
@@ -114,29 +119,45 @@ export async function previewLocalSkillPackage(
|
||||
input: LocalSkillInstallInput
|
||||
): Promise<LocalSkillInstallPreview> {
|
||||
const extracted = await inspectExtractedPackage(input)
|
||||
const canonicalPath = join(input.destinationRoot, extracted.manifest.name)
|
||||
try {
|
||||
const receipt = await readSkillInstallReceipt(input.stateDirectory, canonicalPath)
|
||||
return {
|
||||
manifest: extracted.manifest,
|
||||
canonicalPath,
|
||||
currentState: await inspectSkillCanonicalState({
|
||||
canonicalPath,
|
||||
manifest: extracted.manifest,
|
||||
receipt
|
||||
})
|
||||
}
|
||||
return previewLocalExtractedSkillPackage(input, extracted)
|
||||
} finally {
|
||||
await (input.filesystem ?? nativeSkillInstallFilesystem).remove(extracted.extractionPath)
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewLocalExtractedSkillPackage(
|
||||
input: LocalSkillInstallInput,
|
||||
extracted: LocalExtractedSkillPackage
|
||||
): Promise<LocalSkillInstallPreview> {
|
||||
const canonicalPath = join(input.destinationRoot, extracted.manifest.name)
|
||||
const receipt = await readSkillInstallReceipt(input.stateDirectory, canonicalPath)
|
||||
return {
|
||||
manifest: extracted.manifest,
|
||||
canonicalPath,
|
||||
currentState: await inspectSkillCanonicalState({
|
||||
canonicalPath,
|
||||
manifest: extracted.manifest,
|
||||
receipt,
|
||||
filesystem: input.filesystem
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function installLocalSkillPackage(
|
||||
input: LocalSkillInstallInput,
|
||||
dependencies: SkillInstallTransactionDependencies = {}
|
||||
): Promise<SkillInstallResult> {
|
||||
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
|
||||
const extracted = await inspectExtractedPackage(input)
|
||||
return installLocalExtractedSkillPackage(input, extracted, dependencies)
|
||||
}
|
||||
|
||||
export async function installLocalExtractedSkillPackage(
|
||||
input: LocalSkillInstallInput,
|
||||
extracted: LocalExtractedSkillPackage,
|
||||
dependencies: SkillInstallTransactionDependencies = {}
|
||||
): Promise<SkillInstallResult> {
|
||||
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
|
||||
const canonicalPath = join(input.destinationRoot, extracted.manifest.name)
|
||||
let releaseLock: (() => Promise<void>) | null = null
|
||||
let journal: SkillInstallJournalV1 | null = null
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillInstallRequest, SkillInstallResult } from '../../shared/skill-install-contract'
|
||||
import type {
|
||||
SkillBundleInstallRequest,
|
||||
SkillBundleInstallResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
@@ -13,7 +17,10 @@ vi.mock('./skill-client-mediated-transfer', () => ({
|
||||
transferSkillPackageToRuntime: mocks.transferSkillPackageToRuntime
|
||||
}))
|
||||
|
||||
import { installSkillOnRemoteRuntime } from './skill-remote-install-service'
|
||||
import {
|
||||
installSkillBundleOnRemoteRuntime,
|
||||
installSkillOnRemoteRuntime
|
||||
} from './skill-remote-install-service'
|
||||
|
||||
const request: SkillInstallRequest = {
|
||||
operationId: 'operation-1',
|
||||
@@ -40,6 +47,30 @@ const result: SkillInstallResult = {
|
||||
placements: []
|
||||
}
|
||||
|
||||
const bundleRequest: SkillBundleInstallRequest = {
|
||||
operationId: 'bundle-operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 12
|
||||
},
|
||||
selectedSkillIds: ['skill-1'],
|
||||
ingress: request.ingress,
|
||||
destination: { scope: 'global' },
|
||||
conflictDecisions: []
|
||||
}
|
||||
|
||||
const bundleResult: SkillBundleInstallResult = {
|
||||
operationId: bundleRequest.operationId,
|
||||
packageId: bundleRequest.package.packageId,
|
||||
versionId: bundleRequest.package.versionId,
|
||||
bundleDigest: bundleRequest.package.bundleDigest,
|
||||
status: 'complete',
|
||||
skills: []
|
||||
}
|
||||
|
||||
function success(value: unknown) {
|
||||
return { id: 'rpc-1', ok: true, result: value, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
@@ -236,3 +267,58 @@ describe('installSkillOnRemoteRuntime', () => {
|
||||
expect(mocks.transferSkillPackageToRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installSkillBundleOnRemoteRuntime', () => {
|
||||
beforeEach(() => {
|
||||
mocks.callRuntimeEnvironment.mockReset()
|
||||
mocks.transferSkillPackageToRuntime.mockReset()
|
||||
})
|
||||
|
||||
it('uses the additive bundle RPC for direct remote installation', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue(success(bundleResult))
|
||||
|
||||
await expect(
|
||||
installSkillBundleOnRemoteRuntime({
|
||||
userDataPath: '/state',
|
||||
environmentId: 'environment-1',
|
||||
request: bundleRequest,
|
||||
capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'],
|
||||
requireHttps: true
|
||||
})
|
||||
).resolves.toEqual(bundleResult)
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
'/state',
|
||||
'environment-1',
|
||||
'skills.installBundle',
|
||||
bundleRequest,
|
||||
5 * 60_000
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to staged upload while preserving the bundle selection', async () => {
|
||||
const cleanup = vi.fn(async () => undefined)
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-1',
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'skill-download-transport-failed' }
|
||||
})
|
||||
.mockResolvedValueOnce(success(bundleResult))
|
||||
mocks.transferSkillPackageToRuntime.mockResolvedValue({ uploadId: 'upload-1', cleanup })
|
||||
|
||||
await expect(
|
||||
installSkillBundleOnRemoteRuntime({
|
||||
userDataPath: '/state',
|
||||
environmentId: 'environment-1',
|
||||
request: bundleRequest,
|
||||
capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'],
|
||||
requireHttps: true
|
||||
})
|
||||
).resolves.toEqual(bundleResult)
|
||||
expect(mocks.callRuntimeEnvironment.mock.calls[1]?.[3]).toMatchObject({
|
||||
selectedSkillIds: ['skill-1'],
|
||||
ingress: { kind: 'staged-upload', uploadId: 'upload-1' }
|
||||
})
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { RuntimeCapability } from '../../shared/protocol-version'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
SkillBundleInstallResultSchema,
|
||||
type SkillBundleInstallRequest,
|
||||
type SkillBundleInstallResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
SkillInstallResultSchema,
|
||||
type SkillInstallRequest,
|
||||
type SkillInstallResult
|
||||
} from '../../shared/skill-install-contract'
|
||||
import { SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability'
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_UPLOAD_CAPABILITY
|
||||
} from '../../shared/skill-install-capability'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import { transferSkillPackageToRuntime } from './skill-client-mediated-transfer'
|
||||
import { SkillInstallFailureSchema } from '../../shared/skill-install-failure'
|
||||
@@ -36,6 +44,20 @@ async function install(
|
||||
)) as RuntimeRpcResponse<unknown>
|
||||
}
|
||||
|
||||
async function installBundle(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
request: SkillBundleInstallRequest
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return (await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'skills.installBundle',
|
||||
request,
|
||||
5 * 60_000
|
||||
)) as RuntimeRpcResponse<unknown>
|
||||
}
|
||||
|
||||
function isDirectDownloadUnavailable(
|
||||
response: RuntimeRpcResponse<unknown>,
|
||||
requireHttps: boolean
|
||||
@@ -125,3 +147,61 @@ export async function installSkillOnRemoteRuntime(input: {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function installSkillBundleOnRemoteRuntime(input: {
|
||||
userDataPath: string
|
||||
environmentId: string
|
||||
request: SkillBundleInstallRequest
|
||||
capabilities: readonly RuntimeCapability[]
|
||||
requireHttps: boolean
|
||||
signal?: AbortSignal
|
||||
}): Promise<SkillBundleInstallResult> {
|
||||
if (
|
||||
input.request.ingress.kind !== 'download-grant' ||
|
||||
!input.capabilities.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)
|
||||
) {
|
||||
throw new Error('skill-bundle-remote-ingress-invalid')
|
||||
}
|
||||
const grant = input.request.ingress
|
||||
const direct = await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableRemoteInstallTransportError,
|
||||
call: () => installBundle(input.userDataPath, input.environmentId, input.request)
|
||||
})
|
||||
if (!isDirectDownloadUnavailable(direct, input.requireHttps)) {
|
||||
if (direct.ok !== true) {
|
||||
throw remoteFailure(direct)
|
||||
}
|
||||
return SkillBundleInstallResultSchema.parse(direct.result)
|
||||
}
|
||||
if (!input.capabilities.includes(SKILL_UPLOAD_CAPABILITY)) {
|
||||
throw new Error('skill-bundle-remote-download-unavailable')
|
||||
}
|
||||
return retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableRemoteInstallTransportError,
|
||||
call: async () => {
|
||||
const transfer = await transferSkillPackageToRuntime({
|
||||
userDataPath: input.userDataPath,
|
||||
environmentId: input.environmentId,
|
||||
transferId: input.request.operationId,
|
||||
package: input.request.package,
|
||||
grant,
|
||||
requireHttps: input.requireHttps,
|
||||
signal: input.signal
|
||||
})
|
||||
try {
|
||||
const staged = await installBundle(input.userDataPath, input.environmentId, {
|
||||
...input.request,
|
||||
ingress: { kind: 'staged-upload', uploadId: transfer.uploadId }
|
||||
})
|
||||
if (staged.ok !== true) {
|
||||
throw remoteFailure(staged)
|
||||
}
|
||||
return SkillBundleInstallResultSchema.parse(staged.result)
|
||||
} finally {
|
||||
await transfer.cleanup().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_MANAGEMENT_CAPABILITY
|
||||
} from '../../shared/skill-install-capability'
|
||||
@@ -14,6 +15,17 @@ export async function supportsSkillRuntimeManagement(
|
||||
)
|
||||
}
|
||||
|
||||
export async function supportsSkillRuntimeBundleInstall(
|
||||
userDataPath: string,
|
||||
environmentId: string
|
||||
): Promise<boolean> {
|
||||
const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, 15_000)
|
||||
return (
|
||||
status.ok === true &&
|
||||
status.result.capabilities?.includes(SKILL_BUNDLE_INSTALL_CAPABILITY) === true
|
||||
)
|
||||
}
|
||||
|
||||
export async function supportsSkillRuntimeInstall(
|
||||
userDataPath: string,
|
||||
environmentId: string
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
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 { SkillSharePreparationService } from './skill-share-preparation-service'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function createSource(): Promise<{ root: string; source: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-share-preparation-'))
|
||||
temporaryDirectories.push(root)
|
||||
const source = join(root, 'source')
|
||||
await mkdir(source)
|
||||
await writeFile(
|
||||
join(source, 'SKILL.md'),
|
||||
'---\nname: retry-skill\ndescription: Retry publication\n---\n\n# Retry\n'
|
||||
)
|
||||
return { root, source }
|
||||
}
|
||||
|
||||
function publishedVersion(packageId: string, versionId: string): SkillCloudVersion {
|
||||
return {
|
||||
packageId,
|
||||
versionId,
|
||||
name: 'retry-skill',
|
||||
description: 'Retry publication',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 100,
|
||||
createdAt: '2026-08-11T12:00:00.000Z',
|
||||
releaseNotes: 'retry',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
packageId,
|
||||
versionId,
|
||||
name: 'retry-skill',
|
||||
description: 'Retry publication',
|
||||
createdAt: '2026-08-11T12:00:00.000Z',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
files: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('SkillSharePreparationService', () => {
|
||||
it('reuses a finalized version when share response delivery is lost', async () => {
|
||||
const { root, source } = await createSource()
|
||||
const publishVersion = vi.fn(async (request: { packageId: string }) => ({
|
||||
status: 'ok' as const,
|
||||
value: publishedVersion(request.packageId, 'version_retry')
|
||||
}))
|
||||
const createShare = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('response lost'))
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
value: { id: 'share_retry', url: 'https://share.test/skills/share/share_retry' }
|
||||
})
|
||||
const service = new SkillSharePreparationService(join(root, 'preparations'), {
|
||||
publishVersion,
|
||||
createShare
|
||||
})
|
||||
const preview = await service.prepare({ sourceDirectory: source })
|
||||
const input = {
|
||||
preparationId: preview.preparationId,
|
||||
releaseNotes: 'retry',
|
||||
userIds: ['user_2'],
|
||||
shareWithOrganization: false
|
||||
}
|
||||
|
||||
await expect(service.publish(input)).rejects.toThrow('response lost')
|
||||
await expect(service.publish(input)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { version: { versionId: 'version_retry' }, share: { id: 'share_retry' } }
|
||||
})
|
||||
|
||||
expect(publishVersion).toHaveBeenCalledOnce()
|
||||
expect(createShare).toHaveBeenCalledTimes(2)
|
||||
const idempotencyKey = createShare.mock.calls[0]?.[1].idempotencyKey
|
||||
expect(idempotencyKey).toMatch(/^[A-Za-z0-9_-]{1,128}$/)
|
||||
expect(idempotencyKey).toBe(createShare.mock.calls[1]?.[1].idempotencyKey)
|
||||
})
|
||||
|
||||
it('keeps the finalized version when the audience must be corrected', async () => {
|
||||
const { root, source } = await createSource()
|
||||
const publishVersion = vi.fn(async (request: { packageId: string }) => ({
|
||||
status: 'ok' as const,
|
||||
value: publishedVersion(request.packageId, 'version_audience')
|
||||
}))
|
||||
const createShare = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('skill_package_organization_required'))
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
value: { id: 'share_people', url: 'https://share.test/skills/share/share_people' }
|
||||
})
|
||||
const service = new SkillSharePreparationService(join(root, 'preparations'), {
|
||||
publishVersion,
|
||||
createShare
|
||||
})
|
||||
const preview = await service.prepare({ sourceDirectory: source })
|
||||
|
||||
await expect(
|
||||
service.publish({
|
||||
preparationId: preview.preparationId,
|
||||
releaseNotes: 'retry',
|
||||
userIds: [],
|
||||
shareWithOrganization: true
|
||||
})
|
||||
).rejects.toThrow('skill_package_organization_required')
|
||||
await expect(
|
||||
service.publish({
|
||||
preparationId: preview.preparationId,
|
||||
releaseNotes: 'retry',
|
||||
userIds: ['user_2'],
|
||||
shareWithOrganization: false
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'ok', value: { share: { id: 'share_people' } } })
|
||||
|
||||
expect(publishVersion).toHaveBeenCalledOnce()
|
||||
expect(createShare.mock.calls[0]?.[1].idempotencyKey).not.toBe(
|
||||
createShare.mock.calls[1]?.[1].idempotencyKey
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
@@ -7,35 +7,58 @@ import type {
|
||||
SkillSharePublishInput,
|
||||
SkillSharePublishOperation
|
||||
} from '../../shared/skill-sharing-contract'
|
||||
import { createSkillPackageArchive, type CreatedSkillPackage } from './skill-package-creation'
|
||||
import type { SkillCloudVersion } from '../../shared/skill-cloud-contract'
|
||||
import { createSkillBundleArchive, type CreatedSkillBundle } from './skill-bundle-creation'
|
||||
import type { SkillCloudService } from './skill-cloud-service'
|
||||
|
||||
const PREPARATION_TTL_MS = 30 * 60 * 1000
|
||||
const MAX_PREPARATIONS = 8
|
||||
|
||||
type Preparation = {
|
||||
created: CreatedSkillPackage
|
||||
created: CreatedSkillBundle
|
||||
expiresAt: number
|
||||
controller: AbortController | null
|
||||
publishedVersion: SkillCloudVersion | null
|
||||
}
|
||||
|
||||
function shareIdempotencyKey(preparationId: string, input: SkillSharePublishInput): string {
|
||||
const audience = JSON.stringify({
|
||||
shareWithOrganization: input.shareWithOrganization,
|
||||
userIds: [...new Set(input.userIds)].sort()
|
||||
})
|
||||
const digest = createHash('sha256').update(audience).digest('hex').slice(0, 32)
|
||||
return `${preparationId}_${digest}`
|
||||
}
|
||||
|
||||
function preview(id: string, value: Preparation): SkillSharePreview {
|
||||
const manifest = value.created.manifest
|
||||
const files = manifest.skills.flatMap((skill) => skill.files)
|
||||
return {
|
||||
preparationId: id,
|
||||
packageId: manifest.packageId,
|
||||
versionId: manifest.versionId,
|
||||
name: manifest.name,
|
||||
name: manifest.bundleName,
|
||||
description: manifest.description,
|
||||
packageDigest: manifest.packageDigest,
|
||||
packageDigest: manifest.bundleDigest,
|
||||
skillCount: manifest.skills.length,
|
||||
skills: manifest.skills.map((skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
digest: skill.digest,
|
||||
fileCount: skill.files.length,
|
||||
totalBytes: skill.files.reduce((total, file) => total + file.size, 0),
|
||||
scriptPaths: skill.files
|
||||
.filter((file) => file.path.startsWith('scripts/'))
|
||||
.map((file) => file.path),
|
||||
executablePaths: skill.files.filter((file) => file.executable).map((file) => file.path)
|
||||
})),
|
||||
archiveSha256: value.created.archiveSha256,
|
||||
fileCount: manifest.files.length,
|
||||
totalBytes: manifest.files.reduce((total, file) => total + file.size, 0),
|
||||
fileCount: files.length,
|
||||
totalBytes: files.reduce((total, file) => total + file.size, 0),
|
||||
compressedBytes: value.created.compressedBytes,
|
||||
scriptPaths: manifest.files
|
||||
.filter((file) => file.path.startsWith('scripts/'))
|
||||
.map((file) => file.path),
|
||||
executablePaths: manifest.files.filter((file) => file.executable).map((file) => file.path),
|
||||
scriptPaths: files.filter((file) => file.path.startsWith('scripts/')).map((file) => file.path),
|
||||
executablePaths: files.filter((file) => file.executable).map((file) => file.path),
|
||||
expiresAt: new Date(value.expiresAt).toISOString()
|
||||
}
|
||||
}
|
||||
@@ -45,11 +68,14 @@ export class SkillSharePreparationService {
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly cloud: Pick<SkillCloudService, 'publish'>
|
||||
private readonly cloud: Pick<SkillCloudService, 'createShare' | 'publishVersion'>
|
||||
) {}
|
||||
|
||||
async prepare(input: {
|
||||
sourceDirectory: string
|
||||
sources?: { id?: string; sourceDirectory: string }[]
|
||||
sourceDirectory?: string
|
||||
bundleName?: string
|
||||
description?: string
|
||||
packageId?: string
|
||||
}): Promise<SkillSharePreview> {
|
||||
await this.prune()
|
||||
@@ -60,8 +86,12 @@ export class SkillSharePreparationService {
|
||||
const directory = join(this.root, preparationId)
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
try {
|
||||
const created = await createSkillPackageArchive({
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
const sources =
|
||||
input.sources ?? (input.sourceDirectory ? [{ sourceDirectory: input.sourceDirectory }] : [])
|
||||
const created = await createSkillBundleArchive({
|
||||
sources,
|
||||
bundleName: input.bundleName ?? 'shared-skill',
|
||||
description: input.description,
|
||||
archivePath: join(directory, 'package.tar.gz'),
|
||||
packageId: input.packageId ?? randomUUID(),
|
||||
versionId: randomUUID()
|
||||
@@ -69,7 +99,8 @@ export class SkillSharePreparationService {
|
||||
const value: Preparation = {
|
||||
created,
|
||||
expiresAt: Date.now() + PREPARATION_TTL_MS,
|
||||
controller: null
|
||||
controller: null,
|
||||
publishedVersion: null
|
||||
}
|
||||
this.preparations.set(preparationId, value)
|
||||
return preview(preparationId, value)
|
||||
@@ -94,21 +125,39 @@ export class SkillSharePreparationService {
|
||||
const controller = new AbortController()
|
||||
preparation.controller = controller
|
||||
try {
|
||||
const result = await this.cloud.publish({
|
||||
archivePath: preparation.created.archivePath,
|
||||
archiveSha256: preparation.created.archiveSha256,
|
||||
compressedBytes: preparation.created.compressedBytes,
|
||||
packageId: preparation.created.manifest.packageId,
|
||||
releaseNotes: input.releaseNotes,
|
||||
let version = preparation.publishedVersion
|
||||
if (!version) {
|
||||
const published = await this.cloud.publishVersion({
|
||||
archivePath: preparation.created.archivePath,
|
||||
archiveSha256: preparation.created.archiveSha256,
|
||||
compressedBytes: preparation.created.compressedBytes,
|
||||
packageId: preparation.created.manifest.packageId,
|
||||
releaseNotes: input.releaseNotes,
|
||||
userIds: input.userIds,
|
||||
shareWithOrganization: input.shareWithOrganization,
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) =>
|
||||
onProgress?.({ preparationId: input.preparationId, ...progress })
|
||||
})
|
||||
if (published.status !== 'ok') {
|
||||
return published
|
||||
}
|
||||
version = published.value
|
||||
preparation.publishedVersion = version
|
||||
}
|
||||
const shared = await this.cloud.createShare(version.packageId, {
|
||||
userIds: input.userIds,
|
||||
shareWithOrganization: input.shareWithOrganization,
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) => onProgress?.({ preparationId: input.preparationId, ...progress })
|
||||
idempotencyKey: shareIdempotencyKey(input.preparationId, input)
|
||||
})
|
||||
if (result.status === 'ok') {
|
||||
await this.release(input.preparationId)
|
||||
if (shared.status !== 'ok') {
|
||||
return shared
|
||||
}
|
||||
return result satisfies SkillSharePublishOperation
|
||||
await this.release(input.preparationId)
|
||||
return {
|
||||
status: 'ok',
|
||||
value: { version, share: shared.value }
|
||||
} satisfies SkillSharePublishOperation
|
||||
} finally {
|
||||
const current = this.preparations.get(input.preparationId)
|
||||
if (current) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { SkillBundleInstallRequest } from '../../shared/skill-bundle-install-contract'
|
||||
import type { SkillInstallRequest } from '../../shared/skill-install-contract'
|
||||
import {
|
||||
SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD
|
||||
} from '../../shared/skill-ssh-relay-contract'
|
||||
import {
|
||||
SKILL_UPLOAD_CHUNK_MAX_BYTES,
|
||||
SkillUploadBeginResultSchema
|
||||
} from '../../shared/skill-upload-session-contract'
|
||||
import { downloadSkillPackageGrant } from './skill-package-download'
|
||||
import {
|
||||
SKILL_SSH_REQUEST_TIMEOUT_MS,
|
||||
retryableSkillSshTransportError,
|
||||
type SkillSshRelayClient
|
||||
} from './skill-ssh-relay-client'
|
||||
import { retrySkillTransferRpc, throwIfSkillTransferCancelled } from './skill-transfer-rpc-retry'
|
||||
|
||||
type SkillSshTransferInput = {
|
||||
userDataPath: string
|
||||
request: SkillInstallRequest | SkillBundleInstallRequest
|
||||
requireHttps: boolean
|
||||
signal?: AbortSignal
|
||||
fetcher?: typeof fetch
|
||||
}
|
||||
|
||||
function allowedOrigins(requireHttps: boolean): string[] {
|
||||
const origins = ['https://storage.googleapis.com']
|
||||
if (!requireHttps && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
|
||||
origins.push(
|
||||
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
return [...new Set(origins)]
|
||||
}
|
||||
|
||||
export async function transferSkillPackageToSshHost(
|
||||
client: SkillSshRelayClient,
|
||||
input: SkillSshTransferInput
|
||||
): Promise<string> {
|
||||
if (input.request.ingress.kind !== 'download-grant') {
|
||||
throw new Error('skill-install-ssh-ingress-invalid')
|
||||
}
|
||||
const downloaded = await downloadSkillPackageGrant({
|
||||
url: input.request.ingress.url,
|
||||
expiresAt: input.request.ingress.expiresAt,
|
||||
expectedArchiveSha256: input.request.package.archiveSha256,
|
||||
expectedCompressedBytes: input.request.package.compressedBytes,
|
||||
temporaryRoot: join(input.userDataPath, 'skill-installs', 'ssh-transfers'),
|
||||
allowedOrigins: allowedOrigins(input.requireHttps),
|
||||
requireHttps: input.requireHttps,
|
||||
signal: input.signal,
|
||||
fetcher: input.fetcher
|
||||
})
|
||||
let uploadId: string | null = null
|
||||
try {
|
||||
const begun = SkillUploadBeginResultSchema.parse(
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
checkCancellationAfterSuccess: false,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD,
|
||||
{ package: input.request.package, transferId: input.request.operationId },
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})
|
||||
)
|
||||
uploadId = begun.uploadId
|
||||
throwIfSkillTransferCancelled(input.signal)
|
||||
const chunkBytes = Math.min(begun.chunkBytes, SKILL_UPLOAD_CHUNK_MAX_BYTES)
|
||||
if (
|
||||
begun.acknowledgedOffset > input.request.package.compressedBytes ||
|
||||
!Number.isInteger(chunkBytes) ||
|
||||
chunkBytes < 1
|
||||
) {
|
||||
throw new Error('skill-transfer-ssh-begin-invalid')
|
||||
}
|
||||
const handle = await open(downloaded.archivePath, 'r')
|
||||
try {
|
||||
let offset = begun.acknowledgedOffset
|
||||
while (offset < input.request.package.compressedBytes) {
|
||||
const bytes = Buffer.alloc(
|
||||
Math.min(chunkBytes, input.request.package.compressedBytes - offset)
|
||||
)
|
||||
const read = await handle.read(bytes, 0, bytes.length, offset)
|
||||
if (read.bytesRead !== bytes.length) {
|
||||
throw new Error('skill-transfer-source-changed')
|
||||
}
|
||||
const acknowledged = (await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD,
|
||||
{ uploadId, offset, bytesBase64: bytes.toString('base64') },
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})) as { acknowledgedOffset: number }
|
||||
if (acknowledged.acknowledgedOffset !== offset + bytes.length) {
|
||||
throw new Error('skill-transfer-ack-invalid')
|
||||
}
|
||||
offset = acknowledged.acknowledgedOffset
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD,
|
||||
{ uploadId },
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})
|
||||
const committedId = uploadId
|
||||
uploadId = null
|
||||
return committedId
|
||||
} finally {
|
||||
await downloaded.cleanup()
|
||||
if (uploadId) {
|
||||
await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { IPtyProvider } from '../providers/pty-provider-contract'
|
||||
|
||||
export const SKILL_SSH_REQUEST_TIMEOUT_MS = 5 * 60_000
|
||||
|
||||
const DIRECT_DOWNLOAD_FAILURE = 'skill-download-transport-failed'
|
||||
const DEVELOPMENT_DOWNLOAD_POLICY_FAILURES = new Set([
|
||||
'skill-download-url-rejected',
|
||||
'skill-download-origin-rejected'
|
||||
])
|
||||
|
||||
export type SkillSshRelayClient = NonNullable<IPtyProvider['requestHostRpc']>
|
||||
|
||||
export function requireSkillSshRelayClient(provider: IPtyProvider): SkillSshRelayClient {
|
||||
if (!provider.requestHostRpc) {
|
||||
throw new Error('skill-install-ssh-relay-unavailable')
|
||||
}
|
||||
return provider.requestHostRpc
|
||||
}
|
||||
|
||||
export function shouldUseSkillSshClientTransfer(error: unknown, requireHttps: boolean): boolean {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
return (
|
||||
message === DIRECT_DOWNLOAD_FAILURE ||
|
||||
(!requireHttps && DEVELOPMENT_DOWNLOAD_POLICY_FAILURES.has(message))
|
||||
)
|
||||
}
|
||||
|
||||
export function retryableSkillSshTransportError(error: unknown): boolean {
|
||||
return (
|
||||
typeof (error as { code?: unknown })?.code !== 'number' &&
|
||||
(error as Error)?.name !== 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
export async function skillSshRelayCapabilities(client: SkillSshRelayClient): Promise<string[]> {
|
||||
const status = (await client('relay.status', {}, { timeoutMs: 15_000 })) as {
|
||||
capabilities?: unknown
|
||||
}
|
||||
return Array.isArray(status.capabilities)
|
||||
? status.capabilities.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_MANAGEMENT_CAPABILITY,
|
||||
@@ -17,78 +15,28 @@ import {
|
||||
type SkillRemoveRequest
|
||||
} from '../../shared/skill-install-contract'
|
||||
import {
|
||||
SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_INSTALL_METHOD,
|
||||
SKILL_SSH_RELAY_LIST_METHOD,
|
||||
SKILL_SSH_RELAY_PREVIEW_METHOD,
|
||||
SKILL_SSH_RELAY_REMOVE_METHOD,
|
||||
SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD,
|
||||
type SkillSshWorkspaceAuthority
|
||||
} from '../../shared/skill-ssh-relay-contract'
|
||||
import {
|
||||
SKILL_UPLOAD_CHUNK_MAX_BYTES,
|
||||
SkillUploadBeginResultSchema
|
||||
} from '../../shared/skill-upload-session-contract'
|
||||
import type { IPtyProvider } from '../providers/pty-provider-contract'
|
||||
import { downloadSkillPackageGrant } from './skill-package-download'
|
||||
import { retrySkillTransferRpc, throwIfSkillTransferCancelled } from './skill-transfer-rpc-retry'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 5 * 60_000
|
||||
const DIRECT_DOWNLOAD_FAILURE = 'skill-download-transport-failed'
|
||||
const DEVELOPMENT_DOWNLOAD_POLICY_FAILURES = new Set([
|
||||
'skill-download-url-rejected',
|
||||
'skill-download-origin-rejected'
|
||||
])
|
||||
|
||||
type SkillSshRelayClient = NonNullable<IPtyProvider['requestHostRpc']>
|
||||
|
||||
function requireClient(provider: IPtyProvider): SkillSshRelayClient {
|
||||
if (!provider.requestHostRpc) {
|
||||
throw new Error('skill-install-ssh-relay-unavailable')
|
||||
}
|
||||
return provider.requestHostRpc
|
||||
}
|
||||
|
||||
function allowedOrigins(requireHttps: boolean): string[] {
|
||||
const origins = ['https://storage.googleapis.com']
|
||||
if (!requireHttps && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
|
||||
origins.push(
|
||||
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
return [...new Set(origins)]
|
||||
}
|
||||
|
||||
function shouldUseClientTransfer(error: unknown, requireHttps: boolean): boolean {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
return (
|
||||
message === DIRECT_DOWNLOAD_FAILURE ||
|
||||
(!requireHttps && DEVELOPMENT_DOWNLOAD_POLICY_FAILURES.has(message))
|
||||
)
|
||||
}
|
||||
|
||||
function retryableSshTransportError(error: unknown): boolean {
|
||||
return (
|
||||
typeof (error as { code?: unknown })?.code !== 'number' &&
|
||||
(error as Error)?.name !== 'AbortError'
|
||||
)
|
||||
}
|
||||
|
||||
async function capabilities(client: SkillSshRelayClient): Promise<string[]> {
|
||||
const status = (await client('relay.status', {}, { timeoutMs: 15_000 })) as {
|
||||
capabilities?: unknown
|
||||
}
|
||||
return Array.isArray(status.capabilities)
|
||||
? status.capabilities.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
}
|
||||
import { retrySkillTransferRpc } from './skill-transfer-rpc-retry'
|
||||
import { transferSkillPackageToSshHost } from './skill-ssh-package-transfer'
|
||||
import {
|
||||
SKILL_SSH_REQUEST_TIMEOUT_MS,
|
||||
requireSkillSshRelayClient,
|
||||
retryableSkillSshTransportError,
|
||||
shouldUseSkillSshClientTransfer,
|
||||
skillSshRelayCapabilities
|
||||
} from './skill-ssh-relay-client'
|
||||
|
||||
export async function supportsSkillManagementOnSsh(provider: IPtyProvider): Promise<boolean> {
|
||||
return (await capabilities(requireClient(provider))).includes(SKILL_MANAGEMENT_CAPABILITY)
|
||||
return (await skillSshRelayCapabilities(requireSkillSshRelayClient(provider))).includes(
|
||||
SKILL_MANAGEMENT_CAPABILITY
|
||||
)
|
||||
}
|
||||
|
||||
export async function installSkillOnSshHost(input: {
|
||||
@@ -100,8 +48,8 @@ export async function installSkillOnSshHost(input: {
|
||||
signal?: AbortSignal
|
||||
fetcher?: typeof fetch
|
||||
}): Promise<SkillInstallResult> {
|
||||
const client = requireClient(input.provider)
|
||||
const supported = await capabilities(client)
|
||||
const client = requireSkillSshRelayClient(input.provider)
|
||||
const supported = await skillSshRelayCapabilities(client)
|
||||
if (!supported.includes(SKILL_INSTALL_CAPABILITY)) {
|
||||
throw new Error('skill-install-ssh-update-required')
|
||||
}
|
||||
@@ -109,19 +57,19 @@ export async function installSkillOnSshHost(input: {
|
||||
return SkillInstallResultSchema.parse(
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSshTransportError,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_INSTALL_METHOD,
|
||||
{ request: input.request, workspace: input.workspace },
|
||||
{ timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
input.request.ingress.kind !== 'download-grant' ||
|
||||
!shouldUseClientTransfer(error, input.requireHttps)
|
||||
!shouldUseSkillSshClientTransfer(error, input.requireHttps)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
@@ -131,7 +79,7 @@ export async function installSkillOnSshHost(input: {
|
||||
}
|
||||
return retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSshTransportError,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: async () => {
|
||||
const uploadId = await transferSkillPackageToSshHost(client, input)
|
||||
try {
|
||||
@@ -145,7 +93,7 @@ export async function installSkillOnSshHost(input: {
|
||||
},
|
||||
workspace: input.workspace
|
||||
},
|
||||
{ timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
@@ -160,8 +108,8 @@ export async function previewSkillInstallOnSshHost(input: {
|
||||
request: SkillInstallPreviewRequest
|
||||
workspace?: SkillSshWorkspaceAuthority
|
||||
}): Promise<SkillInstallPreview> {
|
||||
const client = requireClient(input.provider)
|
||||
if (!(await capabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) {
|
||||
const client = requireSkillSshRelayClient(input.provider)
|
||||
if (!(await skillSshRelayCapabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) {
|
||||
throw new Error('skill-install-ssh-update-required')
|
||||
}
|
||||
return SkillInstallPreviewSchema.parse(
|
||||
@@ -178,15 +126,15 @@ export async function removeSkillInstallOnSshHost(input: {
|
||||
request: SkillRemoveRequest
|
||||
workspace?: SkillSshWorkspaceAuthority
|
||||
}): Promise<SkillInstallResult> {
|
||||
const client = requireClient(input.provider)
|
||||
if (!(await capabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) {
|
||||
const client = requireSkillSshRelayClient(input.provider)
|
||||
if (!(await skillSshRelayCapabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) {
|
||||
throw new Error('skill-install-ssh-update-required')
|
||||
}
|
||||
return SkillInstallResultSchema.parse(
|
||||
await client(
|
||||
SKILL_SSH_RELAY_REMOVE_METHOD,
|
||||
{ request: input.request, workspace: input.workspace },
|
||||
{ timeoutMs: REQUEST_TIMEOUT_MS }
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -196,8 +144,8 @@ export async function listSkillInstallsOnSshHost(input: {
|
||||
connectionId: string
|
||||
workspaces: SkillSshWorkspaceAuthority[]
|
||||
}): Promise<ManagedSkillInstall[]> {
|
||||
const client = requireClient(input.provider)
|
||||
if (!(await capabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) {
|
||||
const client = requireSkillSshRelayClient(input.provider)
|
||||
if (!(await skillSshRelayCapabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) {
|
||||
throw new Error('skill-install-ssh-update-required')
|
||||
}
|
||||
return ManagedSkillInstallListSchema.parse(
|
||||
@@ -217,96 +165,3 @@ export async function listSkillInstallsOnSshHost(input: {
|
||||
: install.destination
|
||||
}))
|
||||
}
|
||||
|
||||
async function transferSkillPackageToSshHost(
|
||||
client: SkillSshRelayClient,
|
||||
input: Parameters<typeof installSkillOnSshHost>[0]
|
||||
): Promise<string> {
|
||||
if (input.request.ingress.kind !== 'download-grant') {
|
||||
throw new Error('skill-install-ssh-ingress-invalid')
|
||||
}
|
||||
const downloaded = await downloadSkillPackageGrant({
|
||||
url: input.request.ingress.url,
|
||||
expiresAt: input.request.ingress.expiresAt,
|
||||
expectedArchiveSha256: input.request.package.archiveSha256,
|
||||
expectedCompressedBytes: input.request.package.compressedBytes,
|
||||
temporaryRoot: join(input.userDataPath, 'skill-installs', 'ssh-transfers'),
|
||||
allowedOrigins: allowedOrigins(input.requireHttps),
|
||||
requireHttps: input.requireHttps,
|
||||
signal: input.signal,
|
||||
fetcher: input.fetcher
|
||||
})
|
||||
let uploadId: string | null = null
|
||||
try {
|
||||
const begun = SkillUploadBeginResultSchema.parse(
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
checkCancellationAfterSuccess: false,
|
||||
retryable: retryableSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD,
|
||||
{ package: input.request.package, transferId: input.request.operationId },
|
||||
{ timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})
|
||||
)
|
||||
uploadId = begun.uploadId
|
||||
throwIfSkillTransferCancelled(input.signal)
|
||||
const chunkBytes = Math.min(begun.chunkBytes, SKILL_UPLOAD_CHUNK_MAX_BYTES)
|
||||
if (
|
||||
begun.acknowledgedOffset > input.request.package.compressedBytes ||
|
||||
!Number.isInteger(chunkBytes) ||
|
||||
chunkBytes < 1
|
||||
) {
|
||||
throw new Error('skill-transfer-ssh-begin-invalid')
|
||||
}
|
||||
const handle = await open(downloaded.archivePath, 'r')
|
||||
try {
|
||||
let offset = begun.acknowledgedOffset
|
||||
while (offset < input.request.package.compressedBytes) {
|
||||
const bytes = Buffer.alloc(
|
||||
Math.min(chunkBytes, input.request.package.compressedBytes - offset)
|
||||
)
|
||||
const read = await handle.read(bytes, 0, bytes.length, offset)
|
||||
if (read.bytesRead !== bytes.length) {
|
||||
throw new Error('skill-transfer-source-changed')
|
||||
}
|
||||
const acknowledged = (await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD,
|
||||
{ uploadId, offset, bytesBase64: bytes.toString('base64') },
|
||||
{ timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})) as { acknowledgedOffset: number }
|
||||
if (acknowledged.acknowledgedOffset !== offset + bytes.length) {
|
||||
throw new Error('skill-transfer-ack-invalid')
|
||||
}
|
||||
offset = acknowledged.acknowledgedOffset
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD,
|
||||
{ uploadId },
|
||||
{ timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})
|
||||
const committedId = uploadId
|
||||
uploadId = null
|
||||
return committedId
|
||||
} finally {
|
||||
await downloaded.cleanup()
|
||||
if (uploadId) {
|
||||
await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
type SkillPackageFile
|
||||
} from '../../shared/skill-package-manifest'
|
||||
import { installSharedSkill } from './skill-install-service'
|
||||
import { createSkillBundleArchive } from './skill-bundle-creation'
|
||||
import { installSkillBundle } from './skill-bundle-install-service'
|
||||
import { writeSkillTarGzip } from './skill-package-tar'
|
||||
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
|
||||
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
|
||||
@@ -124,4 +126,54 @@ describe.runIf(RUN_REAL_WSL)('real WSL POSIX skill semantics', () => {
|
||||
await expect(runWsl('test', '!', '-e', `${skill}/skill.md`)).resolves.toBe('')
|
||||
await expect(runWsl(`${skill}/scripts/run.sh`)).resolves.toBe('ok')
|
||||
})
|
||||
|
||||
it('installs only selected bundle skills into the distro-owned home', async () => {
|
||||
const sources = join(localRoot, 'bundle-sources')
|
||||
const alpha = join(sources, 'wsl-alpha')
|
||||
const beta = join(sources, 'wsl-beta')
|
||||
await Promise.all([mkdir(alpha, { recursive: true }), mkdir(beta, { recursive: true })])
|
||||
await writeFile(
|
||||
join(alpha, 'SKILL.md'),
|
||||
'---\nname: wsl-alpha\ndescription: WSL alpha\n---\n\n# Alpha\n'
|
||||
)
|
||||
await writeFile(
|
||||
join(beta, 'SKILL.md'),
|
||||
'---\nname: wsl-beta\ndescription: WSL beta\n---\n\n# Beta\n'
|
||||
)
|
||||
const bundle = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }],
|
||||
archivePath: join(localRoot, 'wsl-bundle.tar.gz'),
|
||||
packageId: 'package-wsl-bundle',
|
||||
versionId: 'version-wsl-bundle',
|
||||
bundleName: 'wsl-bundle'
|
||||
})
|
||||
const homeDirectory = uncPath(`${guestRoot}/home`)
|
||||
const filesystem = createWslSkillInstallFilesystem({ distro: DISTRO, homeDirectory })
|
||||
|
||||
const result = await installSkillBundle({
|
||||
operationId: 'wsl-bundle-install',
|
||||
archivePath: bundle.archivePath,
|
||||
packageId: bundle.manifest.packageId,
|
||||
versionId: bundle.manifest.versionId,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
selectedSkillIds: ['wsl-alpha'],
|
||||
expectedArchiveSha256: bundle.archiveSha256,
|
||||
scope: 'global',
|
||||
homeDirectory,
|
||||
orcaStateDirectory: join(localRoot, 'bundle-state'),
|
||||
detectedProviders: [],
|
||||
destinationIdentity: 'global:wsl-bundle',
|
||||
hostIdentity: 'windows-2',
|
||||
filesystem,
|
||||
wslDistro: DISTRO
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ status: 'complete', skills: [{ name: 'wsl-alpha' }] })
|
||||
await expect(
|
||||
runWsl('test', '-f', `${guestRoot}/home/.agents/skills/wsl-alpha/SKILL.md`)
|
||||
).resolves.toBe('')
|
||||
await expect(
|
||||
runWsl('test', '!', '-e', `${guestRoot}/home/.agents/skills/wsl-beta`)
|
||||
).resolves.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -407,6 +407,11 @@ import type {
|
||||
SkillCloudPackageDetails
|
||||
} from '../shared/skill-cloud-contract'
|
||||
import type {
|
||||
SkillBundleInstallPreviewInput,
|
||||
SkillBundleInstallPreviewOperation,
|
||||
SkillBundlePackageVersionInstallInput,
|
||||
SkillBundleShareInstallInput,
|
||||
SkillBundleShareInstallOperation,
|
||||
SkillInstallPreviewInput,
|
||||
SkillInstallPreviewOperation,
|
||||
ManagedSkillInstallListOperation,
|
||||
@@ -2569,7 +2574,8 @@ export type PreloadApi = {
|
||||
acknowledgeUpdateRun: () => Promise<void>
|
||||
getUpdateRun: () => Promise<SkillUpdateRun>
|
||||
prepareShare: (input: {
|
||||
skillId: string
|
||||
skillIds: string[]
|
||||
bundleName: string
|
||||
target?: SkillDiscoveryTarget
|
||||
packageId?: string
|
||||
}) => Promise<SkillSharePreview>
|
||||
@@ -2579,11 +2585,20 @@ export type PreloadApi = {
|
||||
resolveShare: (shareId: string) => Promise<SkillShareResolvedOperation>
|
||||
createDownloadGrant: (shareId: string) => Promise<SkillCloudOperation<SkillCloudDownloadGrant>>
|
||||
installShare: (input: SkillShareInstallInput) => Promise<SkillShareInstallOperation>
|
||||
installBundleShare: (
|
||||
input: SkillBundleShareInstallInput
|
||||
) => Promise<SkillBundleShareInstallOperation>
|
||||
installBundlePackageVersion: (
|
||||
input: SkillBundlePackageVersionInstallInput
|
||||
) => Promise<SkillBundleShareInstallOperation>
|
||||
installPackageVersion: (
|
||||
input: SkillPackageVersionInstallInput
|
||||
) => Promise<SkillShareInstallOperation>
|
||||
cancelInstall: (input: SkillInstallCancelInput) => Promise<{ cancelled: boolean }>
|
||||
previewInstall: (input: SkillInstallPreviewInput) => Promise<SkillInstallPreviewOperation>
|
||||
previewBundleInstall: (
|
||||
input: SkillBundleInstallPreviewInput
|
||||
) => Promise<SkillBundleInstallPreviewOperation>
|
||||
removeInstall: (input: SkillRemoveInput) => Promise<SkillRemoveOperation>
|
||||
listManagedInstalls: (environmentId?: string) => Promise<ManagedSkillInstallListOperation>
|
||||
getPackage: (packageId: string) => Promise<SkillCloudOperation<SkillCloudPackageDetails>>
|
||||
|
||||
+19
-1
@@ -135,6 +135,11 @@ import type {
|
||||
SkillCloudPackageDetails
|
||||
} from '../shared/skill-cloud-contract'
|
||||
import type {
|
||||
SkillBundleInstallPreviewInput,
|
||||
SkillBundleInstallPreviewOperation,
|
||||
SkillBundlePackageVersionInstallInput,
|
||||
SkillBundleShareInstallInput,
|
||||
SkillBundleShareInstallOperation,
|
||||
SkillInstallPreviewInput,
|
||||
SkillInstallPreviewOperation,
|
||||
ManagedSkillInstallListOperation,
|
||||
@@ -2519,7 +2524,8 @@ const api = {
|
||||
acknowledgeUpdateRun: (): Promise<void> => ipcRenderer.invoke('skills:acknowledgeUpdateRun'),
|
||||
getUpdateRun: (): Promise<SkillUpdateRun> => ipcRenderer.invoke('skills:getUpdateRun'),
|
||||
prepareShare: (input: {
|
||||
skillId: string
|
||||
skillIds: string[]
|
||||
bundleName: string
|
||||
target?: SkillDiscoveryTarget
|
||||
packageId?: string
|
||||
}): Promise<SkillSharePreview> => ipcRenderer.invoke('skills:prepareShare', input),
|
||||
@@ -2535,6 +2541,14 @@ const api = {
|
||||
ipcRenderer.invoke('skills:createDownloadGrant', shareId),
|
||||
installShare: (input: SkillShareInstallInput): Promise<SkillShareInstallOperation> =>
|
||||
ipcRenderer.invoke('skills:installShare', input),
|
||||
installBundleShare: (
|
||||
input: SkillBundleShareInstallInput
|
||||
): Promise<SkillBundleShareInstallOperation> =>
|
||||
ipcRenderer.invoke('skills:installBundleShare', input),
|
||||
installBundlePackageVersion: (
|
||||
input: SkillBundlePackageVersionInstallInput
|
||||
): Promise<SkillBundleShareInstallOperation> =>
|
||||
ipcRenderer.invoke('skills:installBundlePackageVersion', input),
|
||||
installPackageVersion: (
|
||||
input: SkillPackageVersionInstallInput
|
||||
): Promise<SkillShareInstallOperation> =>
|
||||
@@ -2543,6 +2557,10 @@ const api = {
|
||||
ipcRenderer.invoke('skills:cancelInstall', input),
|
||||
previewInstall: (input: SkillInstallPreviewInput): Promise<SkillInstallPreviewOperation> =>
|
||||
ipcRenderer.invoke('skills:previewInstall', input),
|
||||
previewBundleInstall: (
|
||||
input: SkillBundleInstallPreviewInput
|
||||
): Promise<SkillBundleInstallPreviewOperation> =>
|
||||
ipcRenderer.invoke('skills:previewBundleInstall', input),
|
||||
removeInstall: (input: SkillRemoveInput): Promise<SkillRemoveOperation> =>
|
||||
ipcRenderer.invoke('skills:removeInstall', input),
|
||||
listManagedInstalls: (environmentId?: string): Promise<ManagedSkillInstallListOperation> =>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MethodHandler, RelayDispatcher } from './dispatcher'
|
||||
import { createSkillPackageArchive } from '../main/skills/skill-package-creation'
|
||||
import { createSkillBundleArchive } from '../main/skills/skill-bundle-creation'
|
||||
import {
|
||||
SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
|
||||
SKILL_SSH_RELAY_INSTALL_METHOD,
|
||||
SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD
|
||||
} from '../shared/skill-ssh-relay-contract'
|
||||
@@ -52,7 +54,7 @@ async function fixture() {
|
||||
isStale: () => false,
|
||||
signal: new AbortController().signal
|
||||
})
|
||||
return { archive, bytes, call, home }
|
||||
return { archive, bytes, call, home, root }
|
||||
}
|
||||
|
||||
describe('SkillInstallHandler', () => {
|
||||
@@ -93,11 +95,65 @@ describe('SkillInstallHandler', () => {
|
||||
it('advertises separately gateable install, upload, and management capabilities', () => {
|
||||
expect(SKILL_RELAY_CAPABILITIES).toEqual([
|
||||
'skills.install.v1',
|
||||
'skills.install.bundle.v1',
|
||||
'skills.upload.v1',
|
||||
'skills.manage.v1'
|
||||
])
|
||||
})
|
||||
|
||||
it('installs a selected bundle through the additive SSH method', async () => {
|
||||
const { call, home, root } = await fixture()
|
||||
const alpha = join(root, 'alpha-skill')
|
||||
const beta = join(root, 'beta-skill')
|
||||
await Promise.all([mkdir(alpha), mkdir(beta)])
|
||||
await writeFile(join(alpha, 'SKILL.md'), '---\nname: alpha-skill\ndescription: Alpha\n---\n')
|
||||
await writeFile(join(beta, 'SKILL.md'), '---\nname: beta-skill\ndescription: Beta\n---\n')
|
||||
const bundle = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'bundle_package',
|
||||
versionId: 'bundle_version',
|
||||
bundleName: 'relay-bundle'
|
||||
})
|
||||
const bundleBytes = await readFile(bundle.archivePath)
|
||||
const packageIdentity = {
|
||||
packageId: bundle.manifest.packageId,
|
||||
versionId: bundle.manifest.versionId,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
archiveSha256: bundle.archiveSha256,
|
||||
compressedBytes: bundleBytes.length
|
||||
}
|
||||
const begun = (await call(SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, {
|
||||
package: packageIdentity
|
||||
})) as { uploadId: string }
|
||||
await call(SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, {
|
||||
uploadId: begun.uploadId,
|
||||
offset: 0,
|
||||
bytesBase64: bundleBytes.toString('base64')
|
||||
})
|
||||
await call(SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, { uploadId: begun.uploadId })
|
||||
const alphaId = bundle.manifest.skills.find((skill) => skill.name === 'alpha-skill')!.id
|
||||
|
||||
const result = (await call(SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, {
|
||||
request: {
|
||||
operationId: 'bundle_operation',
|
||||
package: packageIdentity,
|
||||
selectedSkillIds: [alphaId],
|
||||
ingress: { kind: 'staged-upload', uploadId: begun.uploadId },
|
||||
destination: { scope: 'global', executionTarget: { kind: 'host' } },
|
||||
conflictDecisions: []
|
||||
}
|
||||
})) as { status: string; skills: { name: string }[] }
|
||||
|
||||
expect(result).toMatchObject({ status: 'complete', skills: [{ name: 'alpha-skill' }] })
|
||||
await expect(
|
||||
readFile(join(home, '.agents', 'skills', 'alpha-skill', 'SKILL.md'))
|
||||
).resolves.toBeDefined()
|
||||
await expect(
|
||||
readFile(join(home, '.agents', 'skills', 'beta-skill', 'SKILL.md'))
|
||||
).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('exposes invalid staged archives through the stable SSH failure contract', async () => {
|
||||
const { call } = await fixture()
|
||||
const bytes = Buffer.from('not a skill archive')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_MANAGEMENT_CAPABILITY,
|
||||
SKILL_UPLOAD_CAPABILITY
|
||||
@@ -10,11 +11,13 @@ import {
|
||||
SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
|
||||
SKILL_SSH_RELAY_INSTALL_METHOD,
|
||||
SKILL_SSH_RELAY_LIST_METHOD,
|
||||
SKILL_SSH_RELAY_PREVIEW_METHOD,
|
||||
SKILL_SSH_RELAY_REMOVE_METHOD,
|
||||
SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD,
|
||||
SkillSshInstallBundleParamsSchema,
|
||||
SkillSshInstallParamsSchema,
|
||||
SkillSshListParamsSchema,
|
||||
SkillSshPreviewParamsSchema,
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
} from '../main/skills/skill-install-management-service'
|
||||
import { listManagedSkillInstalls } from '../main/skills/skill-install-provenance'
|
||||
import { executeSkillInstallRequest } from '../main/skills/skill-install-request-service'
|
||||
import { executeSkillBundleInstallRequest } from '../main/skills/skill-bundle-install-request-service'
|
||||
import { SkillUploadSessionService } from '../main/skills/skill-upload-session-service'
|
||||
import {
|
||||
SkillInstallOperationError,
|
||||
@@ -50,6 +54,7 @@ const SSH_SKILL_ENVIRONMENT_ID = 'ssh-host'
|
||||
|
||||
export const SKILL_RELAY_CAPABILITIES = [
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_UPLOAD_CAPABILITY,
|
||||
SKILL_MANAGEMENT_CAPABILITY
|
||||
] as const
|
||||
@@ -92,6 +97,20 @@ export class SkillInstallHandler {
|
||||
})
|
||||
)
|
||||
})
|
||||
this.dispatcher.onRequest(SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, async (params, context) => {
|
||||
const input = SkillSshInstallBundleParamsSchema.parse(params)
|
||||
return this.executeSkillOperation(() =>
|
||||
executeSkillBundleInstallRequest(input.request, {
|
||||
authority: this.authority(input.workspace),
|
||||
stateDirectory: this.stateDirectory,
|
||||
allowedDownloadOrigins: ['https://storage.googleapis.com'],
|
||||
requireHttps: true,
|
||||
resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity),
|
||||
detectProviders: this.detectProviders,
|
||||
signal: context.signal
|
||||
})
|
||||
)
|
||||
})
|
||||
this.dispatcher.onRequest(SKILL_SSH_RELAY_PREVIEW_METHOD, async (params) => {
|
||||
const input = SkillSshPreviewParamsSchema.parse(params)
|
||||
return this.executeSkillOperation(() =>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Download, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogFooter } from '@/components/ui/dialog'
|
||||
import { useAppStore } from '@/store'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { SKILL_BUNDLE_INSTALL_CAPABILITY } from '../../../../shared/skill-install-capability'
|
||||
import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
|
||||
import type {
|
||||
SkillBundleInstallPreview,
|
||||
SkillBundleInstallResult
|
||||
} from '../../../../shared/skill-bundle-install-contract'
|
||||
import type { SkillInstallDestination } from '../../../../shared/skill-install-contract'
|
||||
import { SkillBundleInstallOutcome } from './SkillBundleInstallOutcome'
|
||||
import { SkillBundleInstallReview } from './SkillBundleInstallReview'
|
||||
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
|
||||
import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
|
||||
import { useSkillInstallProgress } from './skill-install-progress-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type BundleVersion = SkillCloudVersion & {
|
||||
manifest: Extract<SkillCloudVersion['manifest'], { skills: unknown }>
|
||||
}
|
||||
|
||||
const CONFLICT_STATES = new Set(['modified', 'unowned', 'external-link', 'name-collision'])
|
||||
|
||||
function sameSelection(preview: SkillBundleInstallPreview, selected: ReadonlySet<string>): boolean {
|
||||
return (
|
||||
preview.skills.length === selected.size &&
|
||||
preview.skills.every((skill) => selected.has(skill.id))
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillBundleInstallFlow(props: {
|
||||
shareId: string
|
||||
version: BundleVersion
|
||||
onClose(): void
|
||||
onBusyChange(busy: boolean): void
|
||||
}): React.JSX.Element {
|
||||
const { onBusyChange } = props
|
||||
const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments)
|
||||
const runtimeStatus = useAppStore((state) => state.runtimeStatusByEnvironmentId)
|
||||
const worktreesByRepo = useAppStore((state) => state.worktreesByRepo)
|
||||
const repos = useAppStore((state) => state.repos)
|
||||
const folderWorkspaces = useAppStore((state) => state.folderWorkspaces)
|
||||
const sshConnectionStates = useAppStore((state) => state.sshConnectionStates)
|
||||
const sshTargetLabels = useAppStore((state) => state.sshTargetLabels)
|
||||
const allSkillIds = useMemo(
|
||||
() => props.version.manifest.skills.map((skill) => skill.id),
|
||||
[props.version.manifest.skills]
|
||||
)
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState(() => new Set(allSkillIds))
|
||||
const [replaceSkillIds, setReplaceSkillIds] = useState<Set<string>>(() => new Set())
|
||||
const [destinationPreview, setDestinationPreview] = useState<SkillBundleInstallPreview | null>(
|
||||
null
|
||||
)
|
||||
const [result, setResult] = useState<SkillBundleInstallResult | null>(null)
|
||||
const [environmentId, setEnvironmentId] = useState('local')
|
||||
const [scope, setScope] = useState<'global' | 'workspace'>('global')
|
||||
const [workspace, setWorkspace] = useState('')
|
||||
const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>(
|
||||
null
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const installProgress = useSkillInstallProgress()
|
||||
|
||||
useEffect(() => onBusyChange(busy), [busy, onBusyChange])
|
||||
|
||||
const workspaceChoices = useMemo(
|
||||
() => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }),
|
||||
[environmentId, folderWorkspaces, repos, worktreesByRepo]
|
||||
)
|
||||
const sshConnections = useMemo(
|
||||
() =>
|
||||
[...sshTargetLabels.entries()].map(([id, label]) => ({
|
||||
id,
|
||||
label,
|
||||
connected: sshConnectionStates.get(id)?.status === 'connected'
|
||||
})),
|
||||
[sshConnectionStates, sshTargetLabels]
|
||||
)
|
||||
|
||||
const resetPreview = (): void => {
|
||||
setDestinationPreview(null)
|
||||
setReplaceSkillIds(new Set())
|
||||
}
|
||||
|
||||
const destination = (): SkillInstallDestination | null => {
|
||||
const choice = workspaceChoices.find((candidate) => candidate.id === workspace)
|
||||
if (scope === 'workspace' && !choice) {
|
||||
return null
|
||||
}
|
||||
if (scope === 'workspace') {
|
||||
return choice?.kind === 'worktree'
|
||||
? { scope: 'workspace', worktreeId: choice.id }
|
||||
: { scope: 'workspace', folderWorkspaceId: choice!.id }
|
||||
}
|
||||
return environmentId.startsWith('ssh:')
|
||||
? {
|
||||
scope: 'global',
|
||||
executionTarget: { kind: 'ssh', connectionId: environmentId.slice('ssh:'.length) }
|
||||
}
|
||||
: { scope: 'global', ...(executionTarget ? { executionTarget } : {}) }
|
||||
}
|
||||
|
||||
const install = async (
|
||||
requestedIds: ReadonlySet<string> = selectedSkillIds,
|
||||
reusePreview = true
|
||||
): Promise<void> => {
|
||||
const target = destination()
|
||||
if (!target || requestedIds.size === 0) {
|
||||
setError(target ? 'Select at least one skill.' : 'Choose a workspace.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const selectedSkills = props.version.manifest.skills
|
||||
.filter((skill) => requestedIds.has(skill.id))
|
||||
.map((skill) => ({ id: skill.id, name: skill.name, digest: skill.digest }))
|
||||
let checked =
|
||||
reusePreview && destinationPreview && sameSelection(destinationPreview, requestedIds)
|
||||
? destinationPreview
|
||||
: null
|
||||
if (!checked) {
|
||||
const operation = await window.api.skills.previewBundleInstall({
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:')
|
||||
? {}
|
||||
: { environmentId }),
|
||||
package: {
|
||||
packageId: props.version.packageId,
|
||||
versionId: props.version.versionId,
|
||||
bundleDigest: props.version.manifest.bundleDigest,
|
||||
archiveSha256: props.version.archiveSha256,
|
||||
compressedBytes: props.version.compressedBytes
|
||||
},
|
||||
selectedSkills,
|
||||
destination: target
|
||||
})
|
||||
if (operation.status === 'unsupported') {
|
||||
setError(operation.message)
|
||||
return
|
||||
}
|
||||
checked = operation.value
|
||||
setDestinationPreview(checked)
|
||||
if (checked.skills.some((skill) => CONFLICT_STATES.has(skill.currentState))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const operationId = crypto.randomUUID()
|
||||
installProgress.begin(operationId)
|
||||
const operation = await window.api.skills.installBundleShare({
|
||||
shareId: props.shareId,
|
||||
versionId: props.version.versionId,
|
||||
operationId,
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
|
||||
selectedSkillIds: [...requestedIds],
|
||||
destination: target,
|
||||
conflictDecisions: checked.skills
|
||||
.filter((skill) => CONFLICT_STATES.has(skill.currentState))
|
||||
.map((skill) => ({
|
||||
skillId: skill.id,
|
||||
resolution: replaceSkillIds.has(skill.id)
|
||||
? ('replace-and-discard-local' as const)
|
||||
: ('keep-local' as const)
|
||||
}))
|
||||
})
|
||||
if (operation.status === 'unsupported') {
|
||||
setError(operation.message)
|
||||
} else if (operation.status !== 'ok') {
|
||||
setError(
|
||||
operation.status === 'reconnect-required'
|
||||
? 'Reconnect your Orca account before installing.'
|
||||
: operation.message
|
||||
)
|
||||
} else {
|
||||
setResult(operation.value)
|
||||
if (
|
||||
operation.value.skills.some((skill) =>
|
||||
['installed', 'updated', 'unchanged'].includes(skill.status)
|
||||
)
|
||||
) {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
console.warn('[skills] bundle install failed:', cause)
|
||||
setError('Installation failed before Orca could verify the requested bundle.')
|
||||
} finally {
|
||||
installProgress.finish()
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelInstall = async (): Promise<void> => {
|
||||
if (!installProgress.activeOperationId) {
|
||||
return
|
||||
}
|
||||
const cancelled = await window.api.skills.cancelInstall({
|
||||
operationId: installProgress.activeOperationId,
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId })
|
||||
})
|
||||
if (!cancelled.cancelled) {
|
||||
setError('The destination had already finished this installation.')
|
||||
}
|
||||
}
|
||||
|
||||
const retryIds = new Set(
|
||||
result?.skills
|
||||
.filter((skill) => skill.status === 'failed' || skill.status === 'cancelled')
|
||||
.map((skill) => skill.skillId) ?? []
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{result ? (
|
||||
<SkillBundleInstallOutcome result={result} />
|
||||
) : (
|
||||
<SkillBundleInstallReview
|
||||
version={props.version}
|
||||
selectedSkillIds={selectedSkillIds}
|
||||
destinationPreview={destinationPreview}
|
||||
replaceSkillIds={replaceSkillIds}
|
||||
busy={busy}
|
||||
onToggleSkill={(id, selected) => {
|
||||
setSelectedSkillIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (selected) {
|
||||
next.add(id)
|
||||
} else {
|
||||
next.delete(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
resetPreview()
|
||||
}}
|
||||
onToggleAll={(selected) => {
|
||||
setSelectedSkillIds(new Set(selected ? allSkillIds : []))
|
||||
resetPreview()
|
||||
}}
|
||||
onToggleReplace={(id, replace) =>
|
||||
setReplaceSkillIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (replace) {
|
||||
next.add(id)
|
||||
} else {
|
||||
next.delete(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
>
|
||||
<SkillInstallTargetFields
|
||||
environmentId={environmentId}
|
||||
onEnvironmentChange={(value) => {
|
||||
setEnvironmentId(value)
|
||||
resetPreview()
|
||||
}}
|
||||
scope={scope}
|
||||
onScopeChange={(value) => {
|
||||
setScope(value)
|
||||
resetPreview()
|
||||
}}
|
||||
workspace={workspace}
|
||||
onWorkspaceChange={(value) => {
|
||||
setWorkspace(value)
|
||||
resetPreview()
|
||||
}}
|
||||
executionTarget={executionTarget}
|
||||
onExecutionTargetChange={(value) => {
|
||||
setExecutionTarget(value)
|
||||
resetPreview()
|
||||
}}
|
||||
runtimeEnvironments={runtimeEnvironments}
|
||||
runtimeStatus={runtimeStatus}
|
||||
sshConnections={sshConnections}
|
||||
workspaceChoices={workspaceChoices}
|
||||
requiredCapability={SKILL_BUNDLE_INSTALL_CAPABILITY}
|
||||
/>
|
||||
</SkillBundleInstallReview>
|
||||
)}
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{installProgress.phaseLabel ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
{installProgress.phaseLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={props.onClose} disabled={busy}>
|
||||
{translate('auto.components.skills.SkillBundleInstallFlow.01c5a13e01', 'Close')}
|
||||
</Button>
|
||||
{busy && installProgress.activeOperationId ? (
|
||||
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallFlow.01c5a13e02',
|
||||
'Cancel installation'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{!result || retryIds.size ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
busy || (!result && (!selectedSkillIds.size || (scope === 'workspace' && !workspace)))
|
||||
}
|
||||
onClick={() => void (result ? install(retryIds, false) : install())}
|
||||
className="w-40"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
{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 }
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { AlertTriangle, Check } from 'lucide-react'
|
||||
import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const RESULT_GROUPS = [
|
||||
'installed',
|
||||
'updated',
|
||||
'unchanged',
|
||||
'kept-local',
|
||||
'failed',
|
||||
'cancelled'
|
||||
] as const
|
||||
|
||||
function resultGroupLabel(status: (typeof RESULT_GROUPS)[number]): string {
|
||||
const labels = {
|
||||
installed: translate(
|
||||
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e01',
|
||||
'Installed'
|
||||
),
|
||||
updated: translate('auto.components.skills.SkillBundleInstallOutcome.01c5a12e02', 'Updated'),
|
||||
unchanged: translate(
|
||||
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e03',
|
||||
'Unchanged'
|
||||
),
|
||||
'kept-local': translate(
|
||||
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e04',
|
||||
'Kept local'
|
||||
),
|
||||
failed: translate('auto.components.skills.SkillBundleInstallOutcome.01c5a12e05', 'Failed'),
|
||||
cancelled: translate('auto.components.skills.SkillBundleInstallOutcome.01c5a12e06', 'Cancelled')
|
||||
}
|
||||
return labels[status]
|
||||
}
|
||||
|
||||
export function SkillBundleInstallOutcome({
|
||||
result
|
||||
}: {
|
||||
result: SkillBundleInstallResult
|
||||
}): React.JSX.Element {
|
||||
const incomplete = result.status !== 'complete'
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-md border border-border p-3"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
{incomplete ? <AlertTriangle className="size-4" /> : <Check className="size-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{incomplete
|
||||
? translate(
|
||||
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e07',
|
||||
'Bundle installation needs attention.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e08',
|
||||
'Skills installed and verified.'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e09',
|
||||
'{{value0}} selected skills checked.',
|
||||
{ value0: result.skills.length }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{RESULT_GROUPS.map((status) => {
|
||||
const skills = result.skills.filter((skill) => skill.status === status)
|
||||
return skills.length ? (
|
||||
<section key={status} className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{resultGroupLabel(status)} · {skills.length}
|
||||
</p>
|
||||
{skills.map((skill) => (
|
||||
<p key={skill.skillId} className="text-xs">
|
||||
{skill.name}
|
||||
{skill.errorCategory ? ` · ${skill.errorCategory}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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 { translate } from '@/i18n/i18n'
|
||||
|
||||
type BundleVersion = SkillCloudVersion & {
|
||||
manifest: Extract<SkillCloudVersion['manifest'], { skills: unknown }>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export function SkillBundleInstallReview(props: {
|
||||
version: BundleVersion
|
||||
selectedSkillIds: ReadonlySet<string>
|
||||
destinationPreview: SkillBundleInstallPreview | null
|
||||
replaceSkillIds: ReadonlySet<string>
|
||||
busy: boolean
|
||||
onToggleSkill(skillId: string, selected: boolean): void
|
||||
onToggleAll(selected: boolean): void
|
||||
onToggleReplace(skillId: string, replace: boolean): void
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{manifest.bundleName}</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{manifest.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e01',
|
||||
'Immutable version'
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e02',
|
||||
'{{value0}} skills',
|
||||
{ value0: selectedSkills.length }
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e03',
|
||||
'{{value0}} files',
|
||||
{ value0: selectedFiles.length }
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e04',
|
||||
'{{value0}} scripts',
|
||||
{
|
||||
value0: selectedFiles.filter((file) => file.path.startsWith('scripts/')).length
|
||||
}
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e05',
|
||||
'{{value0}} executable',
|
||||
{ value0: selectedFiles.filter((file) => file.executable).length }
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
{props.destinationPreview ? (
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground" role="status">
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e14',
|
||||
'{{value0}} new',
|
||||
{ value0: previewSkills.filter((skill) => skill.currentState === 'missing').length }
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e15',
|
||||
'{{value0}} unchanged',
|
||||
{
|
||||
value0: previewSkills.filter((skill) => skill.currentState === 'unchanged').length
|
||||
}
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e16',
|
||||
'{{value0}} updates',
|
||||
{
|
||||
value0: previewSkills.filter((skill) => skill.currentState === 'clean-update')
|
||||
.length
|
||||
}
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e17',
|
||||
'{{value0}} conflicts',
|
||||
{ value0: conflicts.length }
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.skills.SkillBundleInstallReview.01c5a11e06', 'SHA-256')}{' '}
|
||||
{manifest.bundleDigest}
|
||||
</p>
|
||||
{props.version.publisher ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e07',
|
||||
'Published by Orca user'
|
||||
)}{' '}
|
||||
{props.version.publisher.userId}
|
||||
{props.version.publisher.organizationId
|
||||
? translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e08',
|
||||
' in organization {{value0}}',
|
||||
{ value0: props.version.publisher.organizationId }
|
||||
)
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
{props.version.releaseNotes.trim() ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e09',
|
||||
'Release notes:'
|
||||
)}{' '}
|
||||
{props.version.releaseNotes}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e0a',
|
||||
'Skills contain instructions and may include scripts. Treat them as code from their author.'
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e0b',
|
||||
'Skills to install'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e0c',
|
||||
'Choose any subset from this bundle.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={allSelected ? true : props.selectedSkillIds.size ? 'indeterminate' : false}
|
||||
disabled={props.busy}
|
||||
onCheckedChange={(checked) => props.onToggleAll(checked === true)}
|
||||
/>
|
||||
{translate('auto.components.skills.SkillBundleInstallReview.01c5a11e0d', 'Select all')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="max-h-52 space-y-1 overflow-y-auto rounded-md border border-border p-1 scrollbar-sleek">
|
||||
{manifest.skills.map((skill) => {
|
||||
const counts = skillCounts(skill)
|
||||
const state = props.destinationPreview?.skills.find((item) => item.id === skill.id)
|
||||
return (
|
||||
<label
|
||||
key={skill.id}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={props.selectedSkillIds.has(skill.id)}
|
||||
disabled={props.busy}
|
||||
onCheckedChange={(checked) => props.onToggleSkill(skill.id, checked === true)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{skill.name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{skill.description ||
|
||||
translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e0e',
|
||||
'No description'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right text-[11px] text-muted-foreground">
|
||||
<span className="block">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e0f',
|
||||
'{{value0}} files · {{value1}} scripts',
|
||||
{ value0: skill.files.length, value1: counts.scripts }
|
||||
)}
|
||||
</span>
|
||||
<span className="block">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e10',
|
||||
'{{value0}} executable',
|
||||
{ value0: counts.executables }
|
||||
)}
|
||||
{state ? ` · ${state.currentState}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{props.children}
|
||||
|
||||
{conflicts.length ? (
|
||||
<section className="space-y-3 rounded-md border border-border p-3" role="alert">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="size-4" />{' '}
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e11',
|
||||
'Local copies need a decision'
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e12',
|
||||
'Orca will keep these local copies by default. Select only the copies you want to discard and replace.'
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{conflicts.map((conflict) => (
|
||||
<label key={conflict.id} className="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={props.replaceSkillIds.has(conflict.id)}
|
||||
disabled={props.busy}
|
||||
onCheckedChange={(checked) =>
|
||||
props.onToggleReplace(conflict.id, checked === true)
|
||||
}
|
||||
/>
|
||||
{translate(
|
||||
'auto.components.skills.SkillBundleInstallReview.01c5a11e13',
|
||||
'Replace {{value0}} ({{value1}})',
|
||||
{ value0: conflict.name, value1: conflict.currentState }
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -27,10 +28,18 @@ function formatUpdatedAt(value: number | null): string {
|
||||
|
||||
export function SkillCard({
|
||||
skill,
|
||||
onShare
|
||||
onShare,
|
||||
selected = false,
|
||||
selectionMode = false,
|
||||
selectionDisabled = false,
|
||||
onSelectionChange
|
||||
}: {
|
||||
skill: DiscoveredSkill
|
||||
onShare?: () => void
|
||||
selected?: boolean
|
||||
selectionMode?: boolean
|
||||
selectionDisabled?: boolean
|
||||
onSelectionChange?: (selected: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const revealSkill = async (): Promise<void> => {
|
||||
const result = await window.api.shell.openInFileManager(skill.skillFilePath)
|
||||
@@ -45,6 +54,19 @@ export function SkillCard({
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{selectionMode ? (
|
||||
<Checkbox
|
||||
className="mt-2"
|
||||
checked={selected}
|
||||
disabled={selectionDisabled}
|
||||
aria-label={translate(
|
||||
'auto.components.skills.SkillCard.01c5a16e01',
|
||||
'Select {{value0}}',
|
||||
{ value0: skill.name }
|
||||
)}
|
||||
onCheckedChange={(value) => onSelectionChange?.(value === true)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background">
|
||||
<BookOpen className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,42 @@ function version(): SkillCloudVersion {
|
||||
}
|
||||
}
|
||||
|
||||
function bundleVersion(): SkillCloudVersion {
|
||||
const skill = (id: string, name: string) => ({
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
digest: DIGEST,
|
||||
files: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
size: 1,
|
||||
executable: false,
|
||||
classification: 'text' as const,
|
||||
sha256: DIGEST,
|
||||
identitySha256: DIGEST
|
||||
}
|
||||
]
|
||||
})
|
||||
return {
|
||||
...version(),
|
||||
name: 'team-bundle',
|
||||
description: 'Two private skills',
|
||||
packageDigest: 'c'.repeat(64),
|
||||
releaseNotes: 'Initial team bundle',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
bundleName: 'team-bundle',
|
||||
description: 'Two private skills',
|
||||
createdAt: '2026-08-11T00:00:00.000Z',
|
||||
skills: [skill('skill-alpha', 'alpha'), skill('skill-beta', 'beta')],
|
||||
bundleDigest: 'c'.repeat(64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installApi(previewInstall: ReturnType<typeof vi.fn>) {
|
||||
let progressListener:
|
||||
| ((progress: { operationId: string; phase: 'authorizing' | 'installing' }) => void)
|
||||
@@ -52,6 +88,7 @@ function installApi(previewInstall: ReturnType<typeof vi.fn>) {
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'ok', value: { id: 'share_1', version: version() } }),
|
||||
previewInstall,
|
||||
previewBundleInstall: vi.fn(),
|
||||
installShare: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
@@ -62,6 +99,7 @@ function installApi(previewInstall: ReturnType<typeof vi.fn>) {
|
||||
placements: []
|
||||
}
|
||||
}),
|
||||
installBundleShare: vi.fn(),
|
||||
cancelInstall: vi.fn().mockResolvedValue({ cancelled: true }),
|
||||
listWslDistros: vi.fn().mockResolvedValue([]),
|
||||
onInstallProgress: vi.fn((listener) => {
|
||||
@@ -75,12 +113,12 @@ function installApi(previewInstall: ReturnType<typeof vi.fn>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectSkill(): Promise<void> {
|
||||
async function inspectSkill(expectedDescription = 'A private skill'): Promise<void> {
|
||||
fireEvent.change(screen.getByLabelText('Orca skill link'), {
|
||||
target: { value: 'https://app.orca.dev/skills/share/share_1' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inspect skill' }))
|
||||
await screen.findByText('A private skill')
|
||||
await screen.findByText(expectedDescription)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -102,6 +140,105 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('SkillInstallDialog', () => {
|
||||
it('installs a selected subset from a shared bundle', async () => {
|
||||
const skills = installApi(vi.fn())
|
||||
skills.resolveShare.mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: { id: 'share_1', version: bundleVersion() }
|
||||
})
|
||||
Object.assign(skills, {
|
||||
previewBundleInstall: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
destinationIdentity: 'local:global',
|
||||
skills: [{ id: 'skill-beta', name: 'beta', digest: DIGEST, currentState: 'missing' }]
|
||||
}
|
||||
}),
|
||||
installBundleShare: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId: 'op_1',
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
status: 'complete',
|
||||
skills: [
|
||||
{
|
||||
skillId: 'skill-beta',
|
||||
name: 'beta',
|
||||
digest: DIGEST,
|
||||
status: 'installed',
|
||||
placements: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
|
||||
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
|
||||
await inspectSkill('Two private skills')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /alpha/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install 1 skills' }))
|
||||
|
||||
await screen.findByText('Skills installed and verified.')
|
||||
expect(skills.installBundleShare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selectedSkillIds: ['skill-beta'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps bundle conflicts local unless replacement is selected', async () => {
|
||||
const skills = installApi(vi.fn())
|
||||
skills.resolveShare.mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: { id: 'share_1', version: bundleVersion() }
|
||||
})
|
||||
Object.assign(skills, {
|
||||
previewBundleInstall: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
destinationIdentity: 'local:global',
|
||||
skills: [
|
||||
{ id: 'skill-alpha', name: 'alpha', digest: DIGEST, currentState: 'modified' },
|
||||
{ id: 'skill-beta', name: 'beta', digest: DIGEST, currentState: 'missing' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
installBundleShare: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId: 'op_1',
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
status: 'partial',
|
||||
skills: []
|
||||
}
|
||||
})
|
||||
})
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
|
||||
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
|
||||
await inspectSkill('Two private skills')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' }))
|
||||
await screen.findByText('Local copies need a decision')
|
||||
expect(skills.installBundleShare).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' }))
|
||||
await waitFor(() => expect(skills.installBundleShare).toHaveBeenCalledOnce())
|
||||
expect(skills.installBundleShare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conflictDecisions: [{ skillId: 'skill-alpha', resolution: 'keep-local' }]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a modified install until the user explicitly discards it', async () => {
|
||||
const previewInstall = vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
|
||||
@@ -17,7 +17,8 @@ import type {
|
||||
} from '../../../../shared/skill-install-contract'
|
||||
import { parseSkillShareId } from './skill-share-link'
|
||||
import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
|
||||
import type { ResolvedSkillShare } from './skill-share-version-summary'
|
||||
import { isSkillBundleVersion, type ResolvedSkillShare } from './skill-share-version-summary'
|
||||
import { SkillBundleInstallFlow } from './SkillBundleInstallFlow'
|
||||
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
|
||||
import {
|
||||
SkillInstallOutcome,
|
||||
@@ -53,6 +54,7 @@ export function SkillInstallDialog({
|
||||
null
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [bundleBusy, setBundleBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<SkillInstallResult | null>(null)
|
||||
const [destinationPreview, setDestinationPreview] = useState<SkillInstallPreview | null>(null)
|
||||
@@ -209,18 +211,26 @@ export function SkillInstallDialog({
|
||||
setScope('global')
|
||||
setWorkspace('')
|
||||
setExecutionTarget(null)
|
||||
setBundleBusy(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const bundleVersion = preview && isSkillBundleVersion(preview.version) ? preview.version : null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
|
||||
<Dialog open={open} onOpenChange={(next) => !next && !busy && !bundleBusy && close()}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate(
|
||||
'auto.components.skills.SkillInstallDialog.fcbec627cc',
|
||||
'Install shared skill'
|
||||
)}
|
||||
{bundleVersion
|
||||
? translate(
|
||||
'auto.components.skills.SkillInstallDialog.01c5a14e01',
|
||||
'Install shared skills'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillInstallDialog.fcbec627cc',
|
||||
'Install shared skill'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
@@ -237,6 +247,13 @@ export function SkillInstallDialog({
|
||||
onLinkChange={setLink}
|
||||
onSubmit={() => void inspect()}
|
||||
/>
|
||||
) : bundleVersion ? (
|
||||
<SkillBundleInstallFlow
|
||||
shareId={preview.shareId}
|
||||
version={bundleVersion}
|
||||
onClose={close}
|
||||
onBusyChange={setBundleBusy}
|
||||
/>
|
||||
) : result && result.status !== 'conflict' ? (
|
||||
<SkillInstallOutcome result={result} />
|
||||
) : (
|
||||
@@ -276,51 +293,57 @@ export function SkillInstallDialog({
|
||||
</SkillInstallReview>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
{!bundleVersion && error ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{installProgress.phaseLabel ? (
|
||||
{!bundleVersion && installProgress.phaseLabel ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
{installProgress.phaseLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
|
||||
{translate('auto.components.skills.SkillInstallDialog.d198ec91e5', 'Close')}
|
||||
</Button>
|
||||
{busy && installProgress.activeOperationId ? (
|
||||
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
|
||||
{translate(
|
||||
'auto.components.skills.SkillInstallDialog.05588076a9',
|
||||
'Cancel installation'
|
||||
)}
|
||||
{!bundleVersion ? (
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
|
||||
{translate('auto.components.skills.SkillInstallDialog.d198ec91e5', 'Close')}
|
||||
</Button>
|
||||
) : null}
|
||||
{preview &&
|
||||
(!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || (scope === 'workspace' && !workspace)}
|
||||
onClick={() => void install()}
|
||||
className="w-32"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
{busy
|
||||
? translate('auto.components.skills.SkillInstallDialog.241e72f9d6', 'Installing…')
|
||||
: result
|
||||
? translate(
|
||||
'auto.components.skills.SkillInstallDialog.59c3b76cdd',
|
||||
'Retry install'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillInstallDialog.39acb9e8f4',
|
||||
'Install skill'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
{busy && installProgress.activeOperationId ? (
|
||||
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
|
||||
{translate(
|
||||
'auto.components.skills.SkillInstallDialog.05588076a9',
|
||||
'Cancel installation'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{preview &&
|
||||
(!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || (scope === 'workspace' && !workspace)}
|
||||
onClick={() => void install()}
|
||||
className="w-32"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
{busy
|
||||
? translate('auto.components.skills.SkillInstallDialog.241e72f9d6', 'Installing…')
|
||||
: result
|
||||
? translate(
|
||||
'auto.components.skills.SkillInstallDialog.59c3b76cdd',
|
||||
'Retry install'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillInstallDialog.39acb9e8f4',
|
||||
'Install skill'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -64,32 +64,50 @@ function packageDetails(versions: SkillCloudVersion[]): SkillCloudPackageDetails
|
||||
}
|
||||
|
||||
function skillsApi(
|
||||
installed: ManagedSkillInstall,
|
||||
installedInput: ManagedSkillInstall | ManagedSkillInstall[],
|
||||
versions: SkillCloudVersion[],
|
||||
details = packageDetails(versions)
|
||||
) {
|
||||
const installed = Array.isArray(installedInput) ? installedInput : [installedInput]
|
||||
let progressListener:
|
||||
| ((progress: { operationId: string; phase: 'authorizing' | 'installing' }) => void)
|
||||
| null = null
|
||||
return {
|
||||
listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: [installed] }),
|
||||
listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: installed }),
|
||||
getPackage: vi.fn().mockResolvedValue({ status: 'ok', value: details }),
|
||||
installPackageVersion: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId: 'operation_1',
|
||||
status: 'updated',
|
||||
name: installed.name,
|
||||
name: installed[0]?.name ?? 'private-skill',
|
||||
packageDigest: DIGEST,
|
||||
placements: []
|
||||
}
|
||||
}),
|
||||
installBundlePackageVersion: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId: 'operation_bundle',
|
||||
packageId: 'pkg_1',
|
||||
versionId: versions[0]?.versionId ?? 'ver_1',
|
||||
bundleDigest: DIGEST,
|
||||
status: 'complete',
|
||||
skills: installed.map((skill) => ({
|
||||
skillId: skill.name,
|
||||
name: skill.name,
|
||||
digest: skill.packageDigest,
|
||||
status: 'updated',
|
||||
placements: []
|
||||
}))
|
||||
}
|
||||
}),
|
||||
removeInstall: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId: 'operation_2',
|
||||
status: 'removed',
|
||||
name: installed.name,
|
||||
name: installed[0]?.name ?? 'private-skill',
|
||||
packageDigest: DIGEST,
|
||||
placements: []
|
||||
}
|
||||
@@ -110,6 +128,34 @@ function skillsApi(
|
||||
}
|
||||
}
|
||||
|
||||
function bundleVersion(versionId: string, names: string[]): SkillCloudVersion {
|
||||
const createdAt = '2026-08-12T00:00:00.000Z'
|
||||
return {
|
||||
...version(versionId, createdAt),
|
||||
name: 'team-skills',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
packageId: 'pkg_1',
|
||||
versionId,
|
||||
bundleName: 'team-skills',
|
||||
description: 'Team skills',
|
||||
createdAt,
|
||||
bundleDigest: DIGEST,
|
||||
skills: names.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
digest: DIGEST,
|
||||
files: []
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bundleInstall(name: string, state: ManagedSkillInstall['state'] = 'unchanged') {
|
||||
return { ...install('ver_1'), name, bundleDigest: DIGEST, state }
|
||||
}
|
||||
|
||||
async function selectInstall(versionId: string): Promise<void> {
|
||||
await screen.findByText(`global · ${versionId}`)
|
||||
fireEvent.click(screen.getByRole('button', { name: new RegExp(`private-skill.*${versionId}`) }))
|
||||
@@ -278,4 +324,69 @@ describe('SkillInstallManagementDialog', () => {
|
||||
expect.objectContaining({ packageId: 'pkg_1', versionId: 'ver_2' })
|
||||
)
|
||||
})
|
||||
|
||||
it('groups bundle receipts and updates only the managed bundle skills', async () => {
|
||||
const installed = [bundleInstall('alpha-skill'), bundleInstall('beta-skill')]
|
||||
const bundle = bundleVersion('ver_2', ['alpha-skill', 'beta-skill', 'new-skill'])
|
||||
const skills = skillsApi(installed, [bundle])
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
|
||||
render(<SkillInstallManagementDialog open onOpenChange={() => 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' }))
|
||||
|
||||
await waitFor(() => expect(skills.installBundlePackageVersion).toHaveBeenCalledOnce())
|
||||
expect(skills.installBundlePackageVersion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_2',
|
||||
selectedSkillIds: ['alpha-skill', 'beta-skill']
|
||||
})
|
||||
)
|
||||
expect(skills.installPackageVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes a bundle with one confirmation and preserves modified skills', async () => {
|
||||
const installed = [bundleInstall('alpha-skill'), bundleInstall('beta-skill', 'modified')]
|
||||
const skills = skillsApi(installed, [bundleVersion('ver_1', ['alpha-skill', 'beta-skill'])])
|
||||
skills.removeInstall
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId: 'remove_alpha',
|
||||
status: 'removed',
|
||||
name: 'alpha-skill',
|
||||
packageDigest: DIGEST,
|
||||
placements: []
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId: 'keep_beta',
|
||||
status: 'conflict',
|
||||
name: 'beta-skill',
|
||||
packageDigest: DIGEST,
|
||||
placements: []
|
||||
}
|
||||
})
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
|
||||
render(<SkillInstallManagementDialog open onOpenChange={() => 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' }))
|
||||
|
||||
await screen.findByText('1 removed · 1 modified skill preserved.')
|
||||
expect(skills.removeInstall).toHaveBeenCalledTimes(2)
|
||||
expect(skills.removeInstall.mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
[expect.objectContaining({ name: 'alpha-skill' })],
|
||||
[expect.objectContaining({ name: 'beta-skill' })]
|
||||
])
|
||||
)
|
||||
expect(skills.removeInstall.mock.calls[1]?.[0]).not.toHaveProperty('conflictResolution')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, Loader2, RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,16 +21,18 @@ import type {
|
||||
ManagedSkillInstall,
|
||||
SkillInstallResult
|
||||
} from '../../../../shared/skill-install-contract'
|
||||
import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract'
|
||||
import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { SkillCloudManagementActions } from './SkillCloudManagementActions'
|
||||
import { skillInstallResultLabel } from './skill-install-result-label'
|
||||
import { skillInstallManagementCopy } from './skill-install-management-copy'
|
||||
import { useSkillInstallProgress } from './skill-install-progress-state'
|
||||
|
||||
function installKey(install: ManagedSkillInstall): string {
|
||||
return `${install.destinationIdentity}:${install.name}:${install.packageId}`
|
||||
}
|
||||
import { SkillManagedInstallList } from './SkillManagedInstallList'
|
||||
import { SkillManagedInstallDetails } from './SkillManagedInstallDetails'
|
||||
import {
|
||||
groupManagedSkillInstalls,
|
||||
type SkillManagedInstallGroup
|
||||
} from './skill-managed-install-groups'
|
||||
|
||||
export function SkillInstallManagementDialog({
|
||||
open,
|
||||
@@ -52,14 +53,17 @@ export function SkillInstallManagementDialog({
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
const [result, setResult] = useState<SkillInstallResult | null>(null)
|
||||
const [bundleResult, setBundleResult] = useState<SkillBundleInstallResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const installProgress = useSkillInstallProgress()
|
||||
|
||||
const groups = useMemo(() => groupManagedSkillInstalls(installs), [installs])
|
||||
const selected = useMemo(
|
||||
() => installs.find((install) => installKey(install) === selectedKey) ?? null,
|
||||
[installs, selectedKey]
|
||||
() => groups.find((group) => group.key === selectedKey) ?? null,
|
||||
[groups, selectedKey]
|
||||
)
|
||||
const selectedInstall = selected?.installs[0] ?? null
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
if (!open) {
|
||||
@@ -91,15 +95,16 @@ export function SkillInstallManagementDialog({
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const selectInstall = async (install: ManagedSkillInstall): Promise<void> => {
|
||||
setSelectedKey(installKey(install))
|
||||
const selectInstall = async (group: SkillManagedInstallGroup): Promise<void> => {
|
||||
setSelectedKey(group.key)
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setResult(null)
|
||||
setBundleResult(null)
|
||||
setConfirmRemove(false)
|
||||
try {
|
||||
const operation = await window.api.skills.getPackage(install.packageId)
|
||||
const operation = await window.api.skills.getPackage(group.packageId)
|
||||
if (operation.status !== 'ok') {
|
||||
setError(
|
||||
operation.status === 'reconnect-required'
|
||||
@@ -109,7 +114,7 @@ export function SkillInstallManagementDialog({
|
||||
return
|
||||
}
|
||||
setDetails(operation.value)
|
||||
setVersionId(operation.value.versions[0]?.versionId ?? install.versionId)
|
||||
setVersionId(operation.value.versions[0]?.versionId ?? group.versionId)
|
||||
} catch (cause) {
|
||||
console.warn('[skills] package history failed:', cause)
|
||||
setError('Version history is unavailable for this skill.')
|
||||
@@ -141,7 +146,7 @@ export function SkillInstallManagementDialog({
|
||||
}
|
||||
|
||||
const installVersion = async (discardLocal = false): Promise<void> => {
|
||||
if (!selected || !versionId) {
|
||||
if (!selected || !selectedInstall || !versionId) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
@@ -150,6 +155,53 @@ export function SkillInstallManagementDialog({
|
||||
const operationId = crypto.randomUUID()
|
||||
installProgress.begin(operationId)
|
||||
try {
|
||||
const version = details?.versions.find((candidate) => candidate.versionId === versionId)
|
||||
const bundleManifest =
|
||||
version?.manifest && 'skills' in version.manifest ? version.manifest : null
|
||||
if (bundleManifest) {
|
||||
const installedNames = new Set(selected.installs.map((install) => install.name))
|
||||
const selectedSkills = bundleManifest.skills.filter((skill) =>
|
||||
installedNames.has(skill.name)
|
||||
)
|
||||
if (selectedSkills.length === 0) {
|
||||
setError('This version does not contain any of the installed bundle skills.')
|
||||
return
|
||||
}
|
||||
const operation = await window.api.skills.installBundlePackageVersion({
|
||||
packageId: selected.packageId,
|
||||
versionId,
|
||||
operationId,
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:')
|
||||
? {}
|
||||
: { environmentId }),
|
||||
selectedSkillIds: selectedSkills.map((skill) => skill.id),
|
||||
destination: selected.destination,
|
||||
...(discardLocal
|
||||
? {
|
||||
conflictDecisions: selectedSkills.map((skill) => ({
|
||||
skillId: skill.id,
|
||||
resolution: 'replace-and-discard-local' as const
|
||||
}))
|
||||
}
|
||||
: {})
|
||||
})
|
||||
if (operation.status !== 'ok') {
|
||||
setError(
|
||||
operation.status === 'reconnect-required'
|
||||
? 'Reconnect your Orca account before changing versions.'
|
||||
: operation.message
|
||||
)
|
||||
return
|
||||
}
|
||||
setBundleResult(operation.value)
|
||||
if (!['failed', 'cancelled'].includes(operation.value.status)) {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
if (operation.value.status === 'complete') {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const operation = await window.api.skills.installPackageVersion({
|
||||
packageId: selected.packageId,
|
||||
versionId,
|
||||
@@ -207,19 +259,41 @@ export function SkillInstallManagementDialog({
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const operation = await window.api.skills.removeInstall({
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
|
||||
name: selected.name,
|
||||
destination: selected.destination,
|
||||
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
|
||||
})
|
||||
if (operation.status !== 'ok') {
|
||||
setError(operation.message)
|
||||
const targets = discardLocal
|
||||
? selected.installs.filter((install) => install.state === 'modified')
|
||||
: selected.installs
|
||||
const operations = await Promise.all(
|
||||
targets.map((install) =>
|
||||
window.api.skills.removeInstall({
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:')
|
||||
? {}
|
||||
: { environmentId }),
|
||||
name: install.name,
|
||||
destination: install.destination,
|
||||
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' as const } : {})
|
||||
})
|
||||
)
|
||||
)
|
||||
const unsupported = operations.find((operation) => operation.status !== 'ok')
|
||||
if (unsupported?.status === 'unsupported') {
|
||||
setError(unsupported.message)
|
||||
return
|
||||
}
|
||||
setResult(operation.value)
|
||||
if (!['conflict', 'failed', 'cancelled'].includes(operation.value.status)) {
|
||||
const values = operations.flatMap((operation) =>
|
||||
operation.status === 'ok' ? [operation.value] : []
|
||||
)
|
||||
const removed = values.filter((value) => value.status === 'removed').length
|
||||
const preserved = values.filter((value) => value.status === 'conflict').length
|
||||
setResult(values.at(-1) ?? null)
|
||||
setNotice(
|
||||
selected.installs.length > 1
|
||||
? `${removed} removed${preserved ? ` · ${preserved} modified skill${preserved === 1 ? '' : 's'} preserved` : ''}.`
|
||||
: null
|
||||
)
|
||||
if (removed > 0) {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
}
|
||||
if (preserved === 0 && values.every((value) => value.status !== 'failed')) {
|
||||
await load()
|
||||
}
|
||||
} catch (cause) {
|
||||
@@ -236,11 +310,14 @@ export function SkillInstallManagementDialog({
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setResult(null)
|
||||
setBundleResult(null)
|
||||
setConfirmRemove(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const destructiveConflict = result?.status === 'conflict' || selected?.state === 'modified'
|
||||
const destructiveConflict =
|
||||
result?.status === 'conflict' ||
|
||||
Boolean(selected?.installs.some((install) => install.state === 'modified'))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
|
||||
@@ -279,101 +356,31 @@ export function SkillInstallManagementDialog({
|
||||
{copy.noInstalls}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{installs.map((install) => (
|
||||
<Button
|
||||
key={installKey(install)}
|
||||
type="button"
|
||||
variant={selectedKey === installKey(install) ? 'secondary' : 'outline'}
|
||||
className="h-auto justify-start p-3 text-left"
|
||||
onClick={() => void selectInstall(install)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">{install.name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{install.scope} · {install.versionId}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={install.state === 'unchanged' ? 'outline' : 'destructive'}
|
||||
className="ml-auto"
|
||||
>
|
||||
{install.state}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<SkillManagedInstallList
|
||||
groups={groups}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={(group) => void selectInstall(group)}
|
||||
/>
|
||||
|
||||
{selected && details ? (
|
||||
<section className="space-y-3 rounded-md border border-border p-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{selected.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{copy.installedVersion} {selected.versionId}
|
||||
</p>
|
||||
</div>
|
||||
<Select value={versionId} onValueChange={setVersionId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={copy.chooseVersion} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{details.versions.map((version) => (
|
||||
<SelectItem key={version.versionId} value={version.versionId}>
|
||||
{version.versionId} · {new Date(version.createdAt).toLocaleDateString()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{destructiveConflict ? (
|
||||
<div className="space-y-2 rounded-md border border-border p-3" role="alert">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="size-4" /> {copy.modified}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{copy.preserveModified}</p>
|
||||
<Button variant="destructive" size="sm" onClick={() => void installVersion(true)}>
|
||||
{copy.discardAndInstall}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => void remove(true)}>
|
||||
{copy.discardAndRemove}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
busy || (versionId === selected.versionId && result?.status !== 'partial')
|
||||
}
|
||||
onClick={() => void installVersion()}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-4" />
|
||||
)}
|
||||
{result?.status === 'partial' ? copy.retryCoverage : copy.installVersion}
|
||||
</Button>
|
||||
{installProgress.activeOperationId ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => void cancelInstall()}>
|
||||
{copy.cancelInstall}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant={confirmRemove ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<Trash2 className="size-4" /> {confirmRemove ? copy.confirmRemove : copy.remove}
|
||||
</Button>
|
||||
</div>
|
||||
<SkillCloudManagementActions
|
||||
details={details}
|
||||
selectedVersionId={versionId}
|
||||
onChanged={refreshPackageDetails}
|
||||
onPackageDeleted={packageDeleted}
|
||||
/>
|
||||
</section>
|
||||
<SkillManagedInstallDetails
|
||||
selected={selected}
|
||||
details={details}
|
||||
versionId={versionId}
|
||||
busy={busy}
|
||||
confirmRemove={confirmRemove}
|
||||
result={result}
|
||||
bundleResult={bundleResult}
|
||||
installActive={Boolean(installProgress.activeOperationId)}
|
||||
destructiveConflict={destructiveConflict}
|
||||
copy={copy}
|
||||
onVersionChange={setVersionId}
|
||||
onInstall={(discardLocal) => void installVersion(discardLocal)}
|
||||
onCancelInstall={() => void cancelInstall()}
|
||||
onRemove={(discardLocal) => void remove(discardLocal)}
|
||||
onCloudChanged={refreshPackageDetails}
|
||||
onPackageDeleted={packageDeleted}
|
||||
/>
|
||||
) : null}
|
||||
{result ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
|
||||
@@ -160,7 +160,7 @@ export function SkillInstallReview({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline">
|
||||
{version.manifest.files.length}{' '}
|
||||
{versionSummary.fileCount}{' '}
|
||||
{translate('auto.components.skills.SkillInstallReviewContent.fab8fce842', 'files')}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
|
||||
@@ -24,6 +24,7 @@ export function SkillInstallTargetFields(props: {
|
||||
runtimeStatus: Map<string, { status: { capabilities?: string[] } | null }>
|
||||
sshConnections: { id: string; label: string; connected: boolean }[]
|
||||
workspaceChoices: SkillInstallWorkspaceChoice[]
|
||||
requiredCapability?: string
|
||||
}): React.JSX.Element {
|
||||
const [wslDistros, setWslDistros] = useState<string[]>([])
|
||||
|
||||
@@ -82,7 +83,9 @@ export function SkillInstallTargetFields(props: {
|
||||
const unsupported =
|
||||
status !== null &&
|
||||
status !== undefined &&
|
||||
status.capabilities?.includes(SKILL_INSTALL_CAPABILITY) !== true
|
||||
status.capabilities?.includes(
|
||||
props.requiredCapability ?? SKILL_INSTALL_CAPABILITY
|
||||
) !== true
|
||||
return (
|
||||
<SelectItem key={environment.id} value={environment.id} disabled={unsupported}>
|
||||
{environment.name}{' '}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { AlertTriangle, Loader2, 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<typeof skillInstallManagementCopy>
|
||||
|
||||
export function SkillManagedInstallDetails({
|
||||
selected,
|
||||
details,
|
||||
versionId,
|
||||
busy,
|
||||
confirmRemove,
|
||||
result,
|
||||
bundleResult,
|
||||
installActive,
|
||||
destructiveConflict,
|
||||
copy,
|
||||
onVersionChange,
|
||||
onInstall,
|
||||
onCancelInstall,
|
||||
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
|
||||
onRemove: (discardLocal?: boolean) => void
|
||||
onCloudChanged: () => Promise<void>
|
||||
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
|
||||
return (
|
||||
<section className="space-y-3 rounded-md border border-border p-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{bundleManifest?.bundleName ?? selected.installs[0]?.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{copy.installedVersion} {selected.versionId}
|
||||
</p>
|
||||
{selected.bundleDigest ? (
|
||||
<div className="mt-2 space-y-1" aria-label={copy.installedBundleSkills}>
|
||||
{selected.installs.map((install) => (
|
||||
<p key={install.name} className="flex justify-between gap-3 text-xs">
|
||||
<span className="truncate">{install.name}</span>
|
||||
<span className="text-muted-foreground">{install.state}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Select value={versionId} onValueChange={onVersionChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={copy.chooseVersion} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{details.versions.map((version) => (
|
||||
<SelectItem key={version.versionId} value={version.versionId}>
|
||||
{version.versionId} · {new Date(version.createdAt).toLocaleDateString()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{destructiveConflict ? (
|
||||
<div className="space-y-2 rounded-md border border-border p-3" role="alert">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="size-4" /> {copy.modified}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{copy.preserveModified}</p>
|
||||
<Button variant="destructive" size="sm" onClick={() => onInstall(true)}>
|
||||
{copy.discardAndInstall}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => onRemove(true)}>
|
||||
{copy.discardAndRemove}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
busy ||
|
||||
(versionId === selected.versionId &&
|
||||
result?.status !== 'partial' &&
|
||||
bundleResult?.status !== 'partial')
|
||||
}
|
||||
onClick={() => onInstall()}
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <RotateCcw className="size-4" />}
|
||||
{result?.status === 'partial' || bundleResult?.status === 'partial'
|
||||
? copy.retryCoverage
|
||||
: selected.bundleDigest
|
||||
? copy.installSkills(selected.installs.length)
|
||||
: copy.installVersion}
|
||||
</Button>
|
||||
{installActive ? (
|
||||
<Button variant="secondary" size="sm" onClick={onCancelInstall}>
|
||||
{copy.cancelInstall}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant={confirmRemove ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => onRemove()}
|
||||
>
|
||||
<Trash2 className="size-4" />{' '}
|
||||
{confirmRemove
|
||||
? selected.bundleDigest
|
||||
? copy.confirmRemoveSkills(selected.installs.length)
|
||||
: copy.confirmRemove
|
||||
: selected.bundleDigest
|
||||
? copy.removeSkills(selected.installs.length)
|
||||
: copy.remove}
|
||||
</Button>
|
||||
</div>
|
||||
<SkillCloudManagementActions
|
||||
details={details}
|
||||
selectedVersionId={versionId}
|
||||
onChanged={onCloudChanged}
|
||||
onPackageDeleted={onPackageDeleted}
|
||||
/>
|
||||
{bundleResult ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
{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
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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 (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{groups.map((group) => {
|
||||
const state = groupInstallState(group)
|
||||
const isBundle = Boolean(group.bundleDigest)
|
||||
return (
|
||||
<Button
|
||||
key={group.key}
|
||||
type="button"
|
||||
variant={selectedKey === group.key ? 'secondary' : 'outline'}
|
||||
className="h-auto justify-start p-3 text-left"
|
||||
onClick={() => onSelect(group)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{isBundle
|
||||
? translate(
|
||||
'auto.components.skills.SkillManagedInstallList.86c76cb262',
|
||||
'{{count}} skill bundle',
|
||||
{ count: group.installs.length }
|
||||
)
|
||||
: group.installs[0]?.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{group.installs[0]?.scope} · {group.versionId}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={state === 'unchanged' ? 'outline' : 'destructive'} className="ml-auto">
|
||||
{state}
|
||||
</Badge>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,7 +106,8 @@ describe('SkillShareDialog', () => {
|
||||
await screen.findByRole('heading', { name: 'Publish new skill version' })
|
||||
expect(screen.getByRole('button', { name: 'Publish new version' })).toBeTruthy()
|
||||
expect(skills.prepareShare).toHaveBeenCalledWith({
|
||||
skillId: skill.id,
|
||||
skillIds: [skill.id],
|
||||
bundleName: skill.name,
|
||||
packageId: 'pkg_1'
|
||||
})
|
||||
})
|
||||
@@ -120,7 +121,8 @@ describe('SkillShareDialog', () => {
|
||||
await screen.findByRole('heading', { name: 'Share skill' })
|
||||
await waitFor(() =>
|
||||
expect(skills.prepareShare).toHaveBeenCalledWith({
|
||||
skillId: skill.id
|
||||
skillIds: [skill.id],
|
||||
bundleName: skill.name
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
import { matchingManagedSkillInstall } from './skill-share-package-selection'
|
||||
|
||||
type SkillShareDialogProps = {
|
||||
skill: DiscoveredSkill | null
|
||||
skills?: DiscoveredSkill[]
|
||||
skill?: DiscoveredSkill | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
@@ -37,10 +38,12 @@ function operationError(status: string): string {
|
||||
}
|
||||
|
||||
export function SkillShareDialog({
|
||||
skills,
|
||||
skill,
|
||||
open,
|
||||
onOpenChange
|
||||
}: SkillShareDialogProps): React.JSX.Element {
|
||||
const selectedSkills = useMemo(() => skills ?? (skill ? [skill] : []), [skill, skills])
|
||||
const [preview, setPreview] = useState<SkillSharePreview | null>(null)
|
||||
const [members, setMembers] = useState<SelectableOrgMember[]>([])
|
||||
const [author, setAuthor] = useState('')
|
||||
@@ -59,7 +62,7 @@ export function SkillShareDialog({
|
||||
const cancellationRequested = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !skill) {
|
||||
if (!open || selectedSkills.length === 0) {
|
||||
return
|
||||
}
|
||||
const current = ++generation.current
|
||||
@@ -74,14 +77,18 @@ export function SkillShareDialog({
|
||||
try {
|
||||
const operation = await window.api.skills.listManagedInstalls()
|
||||
if (operation.status === 'ok') {
|
||||
managedInstall = matchingManagedSkillInstall(skill, operation.value)
|
||||
managedInstall =
|
||||
selectedSkills.length === 1
|
||||
? matchingManagedSkillInstall(selectedSkills[0], operation.value)
|
||||
: null
|
||||
}
|
||||
} catch (cause) {
|
||||
console.warn('[skills] managed install lookup failed during share:', cause)
|
||||
}
|
||||
const [nextPreview, auth] = await Promise.all([
|
||||
window.api.skills.prepareShare({
|
||||
skillId: skill.id,
|
||||
skillIds: selectedSkills.map((skill) => skill.id),
|
||||
bundleName: selectedSkills.length === 1 ? selectedSkills[0].name : 'shared-skills',
|
||||
...(managedInstall ? { packageId: managedInstall.packageId } : {})
|
||||
}),
|
||||
window.api.orcaProfiles.authStatus()
|
||||
@@ -129,7 +136,7 @@ export function SkillShareDialog({
|
||||
setPreparing(false)
|
||||
}
|
||||
})
|
||||
}, [open, skill])
|
||||
}, [open, selectedSkills])
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) {
|
||||
|
||||
@@ -93,6 +93,7 @@ export function SkillSharePreparationReview({
|
||||
progress: SkillShareProgress | null
|
||||
progressPercent: number
|
||||
}): React.JSX.Element {
|
||||
const skillPreviews = preview.skills ?? []
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
@@ -106,6 +107,13 @@ export function SkillSharePreparationReview({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline">
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.01c5a17e01',
|
||||
'{{value0}} skills',
|
||||
{ value0: preview.skillCount ?? preview.skills?.length ?? 1 }
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{preview.fileCount}{' '}
|
||||
{translate('auto.components.skills.SkillShareReviewContent.3121f44358', 'files')}
|
||||
@@ -124,6 +132,33 @@ export function SkillSharePreparationReview({
|
||||
{translate('auto.components.skills.SkillShareReviewContent.b3b1d4b911', 'SHA-256')}{' '}
|
||||
{preview.packageDigest}
|
||||
</p>
|
||||
{skillPreviews.length > 1 ? (
|
||||
<details className="rounded-md border border-border px-3 py-2 text-xs">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.01c5a17e02',
|
||||
'Review included skills'
|
||||
)}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{skillPreviews.map((skill) => (
|
||||
<div key={skill.id} className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{skill.name}</p>
|
||||
<p className="truncate text-muted-foreground">{skill.description}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.01c5a17e03',
|
||||
'{{value0}} files · {{value1}}',
|
||||
{ value0: skill.fileCount, value1: byteLabel(skill.totalBytes) }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import { SkillCard } from './SkillCard'
|
||||
import { isSkillShareEligible } from './SkillShareSelectionControls'
|
||||
|
||||
export function SkillShareSelectableCard({
|
||||
skill,
|
||||
local,
|
||||
selected,
|
||||
selectionMode,
|
||||
onSelectedChange,
|
||||
onShare
|
||||
}: {
|
||||
skill: DiscoveredSkill
|
||||
local: boolean
|
||||
selected: boolean
|
||||
selectionMode: boolean
|
||||
onSelectedChange: (selected: boolean) => void
|
||||
onShare: () => void
|
||||
}): React.JSX.Element {
|
||||
const eligible = isSkillShareEligible(skill, local)
|
||||
return (
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
selected={selected}
|
||||
selectionMode={selectionMode}
|
||||
selectionDisabled={!eligible}
|
||||
onSelectionChange={onSelectedChange}
|
||||
onShare={eligible ? onShare : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Share2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function isSkillShareEligible(skill: DiscoveredSkill, local: boolean): boolean {
|
||||
return local && skill.installed && (skill.sourceKind === 'home' || skill.sourceKind === 'repo')
|
||||
}
|
||||
|
||||
export function updatedSkillSelection(
|
||||
current: ReadonlySet<string>,
|
||||
skillId: string,
|
||||
selected: boolean
|
||||
): Set<string> {
|
||||
const next = new Set(current)
|
||||
if (selected) {
|
||||
next.add(skillId)
|
||||
} else {
|
||||
next.delete(skillId)
|
||||
}
|
||||
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 (
|
||||
<Button type="button" variant={selecting ? 'secondary' : 'outline'} size="sm" onClick={onClick}>
|
||||
<Share2 className="size-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillShareSelectionStatus({
|
||||
selectedCount,
|
||||
onSelectAll
|
||||
}: {
|
||||
selectedCount: number
|
||||
onSelectAll: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareSelectionControls.01c5a15e03',
|
||||
'{{value0}} selected',
|
||||
{ value0: selectedCount }
|
||||
)}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="xs" onClick={onSelectAll}>
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareSelectionControls.01c5a15e04',
|
||||
'Select all results'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ 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 { SkillCard } from './SkillCard'
|
||||
import { SkillShareSelectableCard } from './SkillShareSelectableCard'
|
||||
import { SkillShareDialog } from './SkillShareDialog'
|
||||
import { SkillInstallDialog } from './SkillInstallDialog'
|
||||
import { SkillInstallManagementDialog } from './SkillInstallManagementDialog'
|
||||
@@ -25,6 +25,12 @@ import { pluralize, sourceLabels } from './skill-display-labels'
|
||||
import { countSkillsBySource, filterSkills, type SkillsFilterState } from './skills-filter'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from '@/hooks/installed-agent-skills-change-event'
|
||||
import {
|
||||
SkillShareSelectionAction,
|
||||
SkillShareSelectionStatus,
|
||||
isSkillShareEligible,
|
||||
updatedSkillSelection
|
||||
} from './SkillShareSelectionControls'
|
||||
|
||||
const EMPTY_SKILLS: DiscoveredSkill[] = []
|
||||
|
||||
@@ -83,7 +89,9 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
|
||||
const [result, setResult] = useState<SkillDiscoveryResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [shareSkill, setShareSkill] = useState<DiscoveredSkill | null>(null)
|
||||
const [shareSkills, setShareSkills] = useState<DiscoveredSkill[]>([])
|
||||
const [selectingShare, setSelectingShare] = useState(false)
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(() => new Set())
|
||||
const [installOpen, setInstallOpen] = useState(false)
|
||||
const [installLink, setInstallLink] = useState('')
|
||||
const [managementOpen, setManagementOpen] = useState(false)
|
||||
@@ -191,8 +199,6 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
const skills = result?.skills ?? EMPTY_SKILLS
|
||||
const visibleSkills = useMemo(() => filterSkills(skills, filters), [filters, skills])
|
||||
const sourceCounts = useMemo(() => countSkillsBySource(skills), [skills])
|
||||
const activeSourceCount = result?.sources.filter((source) => source.exists).length ?? 0
|
||||
|
||||
return (
|
||||
<main className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border px-5 py-3">
|
||||
@@ -214,10 +220,22 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{pluralize(skills.length, 'skill')}{' '}
|
||||
{translate('auto.components.skills.SkillsPage.e46e162e2e', 'from')}
|
||||
{pluralize(activeSourceCount, 'source')}
|
||||
{pluralize(result?.sources.filter((source) => source.exists).length ?? 0, 'source')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SkillShareSelectionAction
|
||||
selecting={selectingShare}
|
||||
selectedCount={selectedSkillIds.size}
|
||||
onClick={() => {
|
||||
if (selectingShare && selectedSkillIds.size > 0) {
|
||||
setShareSkills(skills.filter((skill) => selectedSkillIds.has(skill.id)))
|
||||
return
|
||||
}
|
||||
setSelectingShare((value) => !value)
|
||||
setSelectedSkillIds(new Set())
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -237,6 +255,20 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
</header>
|
||||
|
||||
<section className="flex shrink-0 flex-col gap-3 border-b border-border px-5 py-4">
|
||||
{selectingShare ? (
|
||||
<SkillShareSelectionStatus
|
||||
selectedCount={selectedSkillIds.size}
|
||||
onSelectAll={() =>
|
||||
setSelectedSkillIds(
|
||||
new Set(
|
||||
visibleSkills
|
||||
.filter((skill) => isSkillShareEligible(skill, runtimeTarget?.kind === 'local'))
|
||||
.map((skill) => skill.id)
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -336,16 +368,18 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
{visibleSkills.length > 0 ? (
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-3">
|
||||
{visibleSkills.map((skill) => (
|
||||
<SkillCard
|
||||
<SkillShareSelectableCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onShare={
|
||||
runtimeTarget?.kind === 'local' &&
|
||||
skill.installed &&
|
||||
(skill.sourceKind === 'home' || skill.sourceKind === 'repo')
|
||||
? () => setShareSkill(skill)
|
||||
: undefined
|
||||
local={runtimeTarget?.kind === 'local'}
|
||||
selected={selectedSkillIds.has(skill.id)}
|
||||
selectionMode={selectingShare}
|
||||
onSelectedChange={(selected) =>
|
||||
setSelectedSkillIds((current) =>
|
||||
updatedSkillSelection(current, skill.id, selected)
|
||||
)
|
||||
}
|
||||
onShare={() => setShareSkills([skill])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -358,9 +392,15 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
)}
|
||||
</section>
|
||||
<SkillShareDialog
|
||||
skill={shareSkill}
|
||||
open={shareSkill !== null}
|
||||
onOpenChange={(nextOpen) => !nextOpen && setShareSkill(null)}
|
||||
skills={shareSkills}
|
||||
open={shareSkills.length > 0}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setShareSkills([])
|
||||
setSelectingShare(false)
|
||||
setSelectedSkillIds(new Set())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SkillInstallDialog
|
||||
key={installLink || 'manual-install'}
|
||||
|
||||
@@ -64,6 +64,34 @@ export function skillInstallManagementCopy() {
|
||||
'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',
|
||||
'{{installed}} installed · {{updated}} updated · {{keptLocal}} kept local',
|
||||
{ installed, updated, keptLocal }
|
||||
),
|
||||
close: translate('auto.components.skills.SkillInstallManagementDialog.8095927ff3', 'Close')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract'
|
||||
|
||||
export type SkillManagedInstallGroup = {
|
||||
key: string
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleDigest?: string
|
||||
destinationIdentity: string
|
||||
destination: ManagedSkillInstall['destination']
|
||||
installs: ManagedSkillInstall[]
|
||||
}
|
||||
|
||||
function groupKey(install: ManagedSkillInstall): string {
|
||||
const identity = [
|
||||
install.destinationIdentity,
|
||||
install.packageId,
|
||||
install.versionId,
|
||||
install.bundleDigest ?? install.name
|
||||
]
|
||||
return identity.join(':')
|
||||
}
|
||||
|
||||
export function groupManagedSkillInstalls(
|
||||
installs: readonly ManagedSkillInstall[]
|
||||
): SkillManagedInstallGroup[] {
|
||||
const groups = new Map<string, SkillManagedInstallGroup>()
|
||||
for (const install of installs) {
|
||||
const key = groupKey(install)
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.installs.push(install)
|
||||
continue
|
||||
}
|
||||
groups.set(key, {
|
||||
key,
|
||||
packageId: install.packageId,
|
||||
versionId: install.versionId,
|
||||
...(install.bundleDigest ? { bundleDigest: install.bundleDigest } : {}),
|
||||
destinationIdentity: install.destinationIdentity,
|
||||
destination: install.destination,
|
||||
installs: [install]
|
||||
})
|
||||
}
|
||||
return [...groups.values()].map((group) => ({
|
||||
...group,
|
||||
installs: group.installs.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}))
|
||||
}
|
||||
|
||||
export function groupInstallState(group: SkillManagedInstallGroup): ManagedSkillInstall['state'] {
|
||||
if (group.installs.some((install) => install.state === 'modified')) {
|
||||
return 'modified'
|
||||
}
|
||||
return group.installs.some((install) => install.state === 'missing') ? 'missing' : 'unchanged'
|
||||
}
|
||||
@@ -1,14 +1,27 @@
|
||||
import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
|
||||
import type { SkillBundleManifestV1 } from '../../../../shared/skill-bundle-manifest'
|
||||
|
||||
export type ResolvedSkillShare = { shareId: string; version: SkillCloudVersion }
|
||||
|
||||
export function isSkillBundleVersion(
|
||||
version: SkillCloudVersion
|
||||
): version is SkillCloudVersion & { manifest: SkillBundleManifestV1 } {
|
||||
return 'skills' in version.manifest
|
||||
}
|
||||
|
||||
export function summarizeSkillShareVersion(version: SkillCloudVersion | undefined): {
|
||||
scriptCount: number
|
||||
executableCount: number
|
||||
fileCount: number
|
||||
} {
|
||||
const files = version
|
||||
? 'skills' in version.manifest
|
||||
? version.manifest.skills.flatMap((skill) => skill.files)
|
||||
: version.manifest.files
|
||||
: []
|
||||
return {
|
||||
scriptCount:
|
||||
version?.manifest.files.filter((file) => file.path.startsWith('scripts/')).length ?? 0,
|
||||
executableCount: version?.manifest.files.filter((file) => file.executable).length ?? 0
|
||||
scriptCount: files.filter((file) => file.path.startsWith('scripts/')).length,
|
||||
executableCount: files.filter((file) => file.executable).length,
|
||||
fileCount: files.length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4147,7 +4147,8 @@
|
||||
"3af85f6add": "Done"
|
||||
},
|
||||
"SkillCard": {
|
||||
"d25a1b8ae6": "Share skill"
|
||||
"d25a1b8ae6": "Share skill",
|
||||
"01c5a16e01": "Select {{value0}}"
|
||||
},
|
||||
"SkillCloudManagementActions": {
|
||||
"ed753624c0": "Delete Cloud package",
|
||||
@@ -4173,7 +4174,8 @@
|
||||
"05588076a9": "Cancel installation",
|
||||
"d198ec91e5": "Close",
|
||||
"4a00d133c5": "Orca verifies access and package identity before changing the selected machine.",
|
||||
"fcbec627cc": "Install shared skill"
|
||||
"fcbec627cc": "Install shared skill",
|
||||
"01c5a14e01": "Install shared skills"
|
||||
},
|
||||
"SkillInstallManagementDialog": {
|
||||
"8095927ff3": "Close",
|
||||
@@ -4193,7 +4195,12 @@
|
||||
"176fef9516": "· SSH",
|
||||
"6cb1fbe039": "This computer",
|
||||
"3677ae58e7": "Update, roll back, or safely remove versions installed by Orca.",
|
||||
"44d118a8f7": "Manage installed skills"
|
||||
"44d118a8f7": "Manage installed skills",
|
||||
"1c9e7f420a": "Installed bundle skills",
|
||||
"34c2ef9e71": "Install {{count}} skills",
|
||||
"a0cab67c2f": "Remove {{count}} skills",
|
||||
"f970d8088d": "Confirm remove {{count}} skills",
|
||||
"dab29e4b54": "{{installed}} installed · {{updated}} updated · {{keptLocal}} kept local"
|
||||
},
|
||||
"SkillInstallReviewContent": {
|
||||
"89e2601162": "Discard and replace",
|
||||
@@ -4257,7 +4264,63 @@
|
||||
"77f636eac3": "executable",
|
||||
"8edd32622f": "scripts",
|
||||
"3121f44358": "files",
|
||||
"2dca0b720b": "Publish new skill version"
|
||||
"2dca0b720b": "Publish new skill version",
|
||||
"01c5a17e01": "{{value0}} skills",
|
||||
"01c5a17e02": "Review included skills",
|
||||
"01c5a17e03": "{{value0}} files · {{value1}}"
|
||||
},
|
||||
"SkillBundleInstallFlow": {
|
||||
"01c5a13e01": "Close",
|
||||
"01c5a13e02": "Cancel installation",
|
||||
"01c5a13e03": "Installing…",
|
||||
"01c5a13e04": "Retry {{value0}} skills",
|
||||
"01c5a13e05": "Install {{value0}} skills"
|
||||
},
|
||||
"SkillBundleInstallOutcome": {
|
||||
"01c5a12e01": "Installed",
|
||||
"01c5a12e02": "Updated",
|
||||
"01c5a12e03": "Unchanged",
|
||||
"01c5a12e04": "Kept local",
|
||||
"01c5a12e05": "Failed",
|
||||
"01c5a12e06": "Cancelled",
|
||||
"01c5a12e07": "Bundle installation needs attention.",
|
||||
"01c5a12e08": "Skills installed and verified.",
|
||||
"01c5a12e09": "{{value0}} selected skills checked."
|
||||
},
|
||||
"SkillBundleInstallReview": {
|
||||
"01c5a11e01": "Immutable version",
|
||||
"01c5a11e02": "{{value0}} skills",
|
||||
"01c5a11e03": "{{value0}} files",
|
||||
"01c5a11e04": "{{value0}} scripts",
|
||||
"01c5a11e05": "{{value0}} executable",
|
||||
"01c5a11e06": "SHA-256",
|
||||
"01c5a11e07": "Published by Orca user",
|
||||
"01c5a11e08": " in organization {{value0}}",
|
||||
"01c5a11e09": "Release notes:",
|
||||
"01c5a11e0a": "Skills contain instructions and may include scripts. Treat them as code from their author.",
|
||||
"01c5a11e0b": "Skills to install",
|
||||
"01c5a11e0c": "Choose any subset from this bundle.",
|
||||
"01c5a11e0d": "Select all",
|
||||
"01c5a11e0e": "No description",
|
||||
"01c5a11e0f": "{{value0}} files · {{value1}} scripts",
|
||||
"01c5a11e10": "{{value0}} executable",
|
||||
"01c5a11e11": "Local copies need a decision",
|
||||
"01c5a11e12": "Orca will keep these local copies by default. Select only the copies you want to discard and replace.",
|
||||
"01c5a11e13": "Replace {{value0}} ({{value1}})",
|
||||
"01c5a11e14": "{{value0}} new",
|
||||
"01c5a11e15": "{{value0}} unchanged",
|
||||
"01c5a11e16": "{{value0}} updates",
|
||||
"01c5a11e17": "{{value0}} conflicts"
|
||||
},
|
||||
"SkillShareSelectionControls": {
|
||||
"01c5a15e05": "Share {{value0}} skills",
|
||||
"01c5a15e01": "Cancel sharing",
|
||||
"01c5a15e02": "Share skills",
|
||||
"01c5a15e03": "{{value0}} selected",
|
||||
"01c5a15e04": "Select all results"
|
||||
},
|
||||
"SkillManagedInstallList": {
|
||||
"86c76cb262": "{{count}} skill bundle"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -3083,10 +3083,14 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
|
||||
createDownloadGrant: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
installBundleShare: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
installPackageVersion: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
cancelInstall: () => Promise.resolve({ cancelled: false }),
|
||||
previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
previewBundleInstall: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
listManagedInstalls: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CANCEL_CAPABILITY,
|
||||
SKILL_INSTALL_RESULT_V2_CAPABILITY,
|
||||
@@ -127,6 +128,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
ACCOUNT_IMPORT_RUNTIME_CAPABILITY,
|
||||
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY,
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CANCEL_CAPABILITY,
|
||||
SKILL_INSTALL_RESULT_V2_CAPABILITY,
|
||||
SKILL_UPLOAD_CAPABILITY,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SkillBundleInstallRequestSchema } from './skill-bundle-install-contract'
|
||||
|
||||
const digest = 'a'.repeat(64)
|
||||
|
||||
function request() {
|
||||
return {
|
||||
operationId: 'operation_1',
|
||||
package: {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: digest,
|
||||
archiveSha256: digest,
|
||||
compressedBytes: 1024
|
||||
},
|
||||
selectedSkillIds: ['alpha', 'beta'],
|
||||
ingress: { kind: 'local-file' as const, path: '/tmp/bundle.tar.gz' },
|
||||
destination: { scope: 'global' as const },
|
||||
conflictDecisions: [{ skillId: 'alpha', resolution: 'keep-local' as const }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('skill bundle install contract', () => {
|
||||
it('accepts one aggregate request with per-skill conflict decisions', () => {
|
||||
expect(SkillBundleInstallRequestSchema.parse(request())).toMatchObject({
|
||||
selectedSkillIds: ['alpha', 'beta'],
|
||||
conflictDecisions: [{ skillId: 'alpha', resolution: 'keep-local' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate selections and decisions for unselected skills', () => {
|
||||
expect(() =>
|
||||
SkillBundleInstallRequestSchema.parse({
|
||||
...request(),
|
||||
selectedSkillIds: ['alpha', 'alpha']
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
SkillBundleInstallRequestSchema.parse({
|
||||
...request(),
|
||||
conflictDecisions: [{ skillId: 'gamma', resolution: 'keep-local' }]
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { z } from 'zod'
|
||||
import { SkillInstallDestinationSchema } from './skill-install-contract'
|
||||
import { SkillInstallFailureSchema, type SkillInstallFailure } from './skill-install-failure'
|
||||
|
||||
const DIGEST_PATTERN = /^[a-f0-9]{64}$/
|
||||
const ID_SCHEMA = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/)
|
||||
const SKILL_NAME_SCHEMA = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
|
||||
|
||||
export const SkillBundlePackageIdentitySchema = z
|
||||
.object({
|
||||
packageId: ID_SCHEMA,
|
||||
versionId: ID_SCHEMA,
|
||||
bundleDigest: z.string().regex(DIGEST_PATTERN),
|
||||
archiveSha256: z.string().regex(DIGEST_PATTERN),
|
||||
compressedBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(40 * 1024 * 1024)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const SkillBundleInstallIngressSchema = z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('download-grant'),
|
||||
url: z.url(),
|
||||
expiresAt: z.iso.datetime({ offset: true })
|
||||
})
|
||||
.strict(),
|
||||
z.object({ kind: z.literal('staged-upload'), uploadId: ID_SCHEMA }).strict(),
|
||||
z.object({ kind: z.literal('local-file'), path: z.string().min(1) }).strict()
|
||||
])
|
||||
|
||||
export const SkillBundleSelectedSkillSchema = z
|
||||
.object({ id: ID_SCHEMA, name: SKILL_NAME_SCHEMA, digest: z.string().regex(DIGEST_PATTERN) })
|
||||
.strict()
|
||||
|
||||
const SkillBundleConflictDecisionSchema = z
|
||||
.object({
|
||||
skillId: ID_SCHEMA,
|
||||
resolution: z.enum(['keep-local', 'replace-unmodified', 'replace-and-discard-local'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
function hasUniqueSkillIds(values: readonly { id?: string; skillId?: string }[]): boolean {
|
||||
const ids = values.map((value) => value.id ?? value.skillId)
|
||||
return new Set(ids).size === ids.length
|
||||
}
|
||||
|
||||
export const SkillBundleInstallRequestSchema = z
|
||||
.object({
|
||||
operationId: ID_SCHEMA,
|
||||
package: SkillBundlePackageIdentitySchema,
|
||||
selectedSkillIds: z.array(ID_SCHEMA).min(1).max(512),
|
||||
ingress: SkillBundleInstallIngressSchema,
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictDecisions: z.array(SkillBundleConflictDecisionSchema).max(512).default([])
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => new Set(value.selectedSkillIds).size === value.selectedSkillIds.length, {
|
||||
message: 'skill-bundle-selection-duplicate'
|
||||
})
|
||||
.refine((value) => hasUniqueSkillIds(value.conflictDecisions), {
|
||||
message: 'skill-bundle-conflict-decision-duplicate'
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
value.conflictDecisions.every((decision) =>
|
||||
value.selectedSkillIds.includes(decision.skillId)
|
||||
),
|
||||
{ message: 'skill-bundle-conflict-decision-unselected' }
|
||||
)
|
||||
|
||||
export const SkillBundleInstallPreviewRequestSchema = z
|
||||
.object({
|
||||
package: SkillBundlePackageIdentitySchema,
|
||||
selectedSkills: z.array(SkillBundleSelectedSkillSchema).min(1).max(512),
|
||||
destination: SkillInstallDestinationSchema
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => hasUniqueSkillIds(value.selectedSkills), {
|
||||
message: 'skill-bundle-selection-duplicate'
|
||||
})
|
||||
|
||||
const SkillBundleCurrentStateSchema = z.enum([
|
||||
'missing',
|
||||
'unchanged',
|
||||
'clean-update',
|
||||
'modified',
|
||||
'unowned',
|
||||
'external-link',
|
||||
'name-collision'
|
||||
])
|
||||
|
||||
export const SkillBundleInstallPreviewSchema = z.object({
|
||||
packageId: ID_SCHEMA,
|
||||
versionId: ID_SCHEMA,
|
||||
bundleDigest: z.string().regex(DIGEST_PATTERN),
|
||||
destinationIdentity: z.string(),
|
||||
skills: z.array(
|
||||
SkillBundleSelectedSkillSchema.extend({ currentState: SkillBundleCurrentStateSchema })
|
||||
)
|
||||
})
|
||||
|
||||
const SkillBundlePlacementResultSchema = z.object({
|
||||
provider: z.string(),
|
||||
path: z.string(),
|
||||
topology: z.enum(['canonical-copy', 'provider-alias', 'independent-copy']),
|
||||
status: z.enum(['installed', 'unchanged', 'skipped', 'failed']),
|
||||
errorCategory: z.string().optional(),
|
||||
failure: SkillInstallFailureSchema.optional()
|
||||
})
|
||||
|
||||
export const SkillBundleInstallResultSchema = z.object({
|
||||
operationId: ID_SCHEMA,
|
||||
packageId: ID_SCHEMA,
|
||||
versionId: ID_SCHEMA,
|
||||
bundleDigest: z.string().regex(DIGEST_PATTERN),
|
||||
status: z.enum(['complete', 'partial', 'cancelled', 'failed']),
|
||||
skills: z.array(
|
||||
z.object({
|
||||
skillId: ID_SCHEMA,
|
||||
name: SKILL_NAME_SCHEMA,
|
||||
digest: z.string().regex(DIGEST_PATTERN),
|
||||
status: z.enum(['installed', 'updated', 'unchanged', 'kept-local', 'failed', 'cancelled']),
|
||||
canonicalPath: z.string().optional(),
|
||||
placements: z.array(SkillBundlePlacementResultSchema),
|
||||
conflict: z
|
||||
.object({
|
||||
kind: z.enum(['modified', 'unowned', 'external-link', 'name-collision']),
|
||||
existingDigest: z.string().optional()
|
||||
})
|
||||
.optional(),
|
||||
errorCategory: z.string().optional(),
|
||||
failure: SkillInstallFailureSchema.optional()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
export type SkillBundlePackageIdentity = z.infer<typeof SkillBundlePackageIdentitySchema>
|
||||
export type SkillBundleSelectedSkill = z.infer<typeof SkillBundleSelectedSkillSchema>
|
||||
export type SkillBundleInstallRequest = z.infer<typeof SkillBundleInstallRequestSchema>
|
||||
export type SkillBundleInstallPreviewRequest = z.infer<
|
||||
typeof SkillBundleInstallPreviewRequestSchema
|
||||
>
|
||||
export type SkillBundleInstallPreview = z.infer<typeof SkillBundleInstallPreviewSchema>
|
||||
export type SkillBundleInstallResult = z.infer<typeof SkillBundleInstallResultSchema>
|
||||
export type SkillBundleSkillResult = SkillBundleInstallResult['skills'][number]
|
||||
export type SkillBundlePlacementResult = SkillBundleSkillResult['placements'][number]
|
||||
export type SkillBundleInstallFailure = SkillInstallFailure
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
SkillPackageFileSchema,
|
||||
computeSkillPackageDigest,
|
||||
validateSkillPackageName,
|
||||
validateSkillPackagePath,
|
||||
type SkillPackageFile
|
||||
} from './skill-package-manifest'
|
||||
|
||||
export const SKILL_BUNDLE_SCHEMA_VERSION = 1 as const
|
||||
export const AGENT_PLUGIN_SCHEMA_V1 =
|
||||
'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' as const
|
||||
export const ORCA_SKILL_BUNDLE_MANIFEST_PATH = 'dev.orca.skill-sharing/manifest.json'
|
||||
export const AGENT_PLUGIN_MANIFEST_PATH = 'plugin.json'
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
||||
const PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/
|
||||
|
||||
const AgentPluginAuthorSchema = z
|
||||
.object({ name: z.string().optional(), email: z.string().optional(), url: z.string().optional() })
|
||||
.strict()
|
||||
|
||||
export const AgentPluginManifestV1Schema = z
|
||||
.object({
|
||||
$schema: z.literal(AGENT_PLUGIN_SCHEMA_V1),
|
||||
name: z.string().min(1).max(64).regex(PLUGIN_NAME_PATTERN),
|
||||
version: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
author: AgentPluginAuthorSchema.optional(),
|
||||
homepage: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
license: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
extensions: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type AgentPluginManifestV1 = z.infer<typeof AgentPluginManifestV1Schema>
|
||||
|
||||
export const SkillBundleEntrySchema = z
|
||||
.object({
|
||||
id: z.string().regex(ID_PATTERN),
|
||||
name: z.string(),
|
||||
description: z.string().max(4096),
|
||||
digest: z.string().regex(SHA256_PATTERN),
|
||||
files: z.array(SkillPackageFileSchema).min(1).max(512)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SkillBundleEntry = z.infer<typeof SkillBundleEntrySchema>
|
||||
|
||||
export const SkillBundleManifestV1Schema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(SKILL_BUNDLE_SCHEMA_VERSION),
|
||||
packageId: z.string().regex(ID_PATTERN),
|
||||
versionId: z.string().regex(ID_PATTERN),
|
||||
bundleName: z.string().min(1).max(64).regex(PLUGIN_NAME_PATTERN),
|
||||
description: z.string().max(4096),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
skills: z.array(SkillBundleEntrySchema).min(1).max(512),
|
||||
bundleDigest: z.string().regex(SHA256_PATTERN)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SkillBundleManifestV1 = z.infer<typeof SkillBundleManifestV1Schema>
|
||||
|
||||
function validateSkillFiles(files: readonly SkillPackageFile[]): void {
|
||||
let previousPath: string | null = null
|
||||
const foldedPaths = new Set<string>()
|
||||
for (const file of files) {
|
||||
validateSkillPackagePath(file.path)
|
||||
if (previousPath !== null && file.path <= previousPath) {
|
||||
throw new Error('skill-bundle-manifest-path-order')
|
||||
}
|
||||
previousPath = file.path
|
||||
const foldedPath = file.path.toLocaleLowerCase('en-US')
|
||||
if (foldedPaths.has(foldedPath)) {
|
||||
throw new Error('skill-bundle-case-collision')
|
||||
}
|
||||
foldedPaths.add(foldedPath)
|
||||
}
|
||||
if (!files.some((file) => file.path === 'SKILL.md')) {
|
||||
throw new Error('skill-package-skill-markdown-required')
|
||||
}
|
||||
}
|
||||
|
||||
export function computeSkillBundleDigest(skills: readonly SkillBundleEntry[]): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify(
|
||||
skills.map((skill) => ({ id: skill.id, name: skill.name, digest: skill.digest }))
|
||||
)
|
||||
)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export function parseAgentPluginManifest(value: unknown): AgentPluginManifestV1 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('skill-bundle-plugin-manifest-invalid')
|
||||
}
|
||||
const candidate = value as Record<string, unknown>
|
||||
const extensions =
|
||||
candidate.extensions &&
|
||||
typeof candidate.extensions === 'object' &&
|
||||
!Array.isArray(candidate.extensions)
|
||||
? candidate.extensions
|
||||
: undefined
|
||||
const parsed = AgentPluginManifestV1Schema.safeParse({
|
||||
$schema: candidate.$schema,
|
||||
name: candidate.name,
|
||||
version: candidate.version,
|
||||
description: candidate.description,
|
||||
author: candidate.author,
|
||||
homepage: candidate.homepage,
|
||||
repository: candidate.repository,
|
||||
license: candidate.license,
|
||||
keywords: candidate.keywords,
|
||||
extensions
|
||||
})
|
||||
if (!parsed.success) {
|
||||
throw new Error('skill-bundle-plugin-manifest-invalid')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export function parseSkillBundleManifest(value: unknown): SkillBundleManifestV1 {
|
||||
const parsed = SkillBundleManifestV1Schema.safeParse(value)
|
||||
if (!parsed.success) {
|
||||
throw new Error('skill-bundle-manifest-invalid')
|
||||
}
|
||||
const foldedNames = new Set<string>()
|
||||
const ids = new Set<string>()
|
||||
let totalFiles = 0
|
||||
let totalBytes = 0
|
||||
let previousName: string | null = null
|
||||
for (const skill of parsed.data.skills) {
|
||||
validateSkillPackageName(skill.name)
|
||||
if (previousName !== null && skill.name <= previousName) {
|
||||
throw new Error('skill-bundle-skill-order')
|
||||
}
|
||||
previousName = skill.name
|
||||
const foldedName = skill.name.toLocaleLowerCase('en-US')
|
||||
if (foldedNames.has(foldedName) || ids.has(skill.id)) {
|
||||
throw new Error('skill-bundle-skill-collision')
|
||||
}
|
||||
foldedNames.add(foldedName)
|
||||
ids.add(skill.id)
|
||||
validateSkillFiles(skill.files)
|
||||
if (computeSkillPackageDigest(skill.files) !== skill.digest) {
|
||||
throw new Error('skill-bundle-skill-digest-mismatch')
|
||||
}
|
||||
totalFiles += skill.files.length
|
||||
totalBytes += skill.files.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalFiles > 512 || totalBytes > 32 * 1024 * 1024) {
|
||||
throw new Error('skill-bundle-content-limit')
|
||||
}
|
||||
}
|
||||
if (computeSkillBundleDigest(parsed.data.skills) !== parsed.data.bundleDigest) {
|
||||
throw new Error('skill-bundle-digest-mismatch')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SkillPackageManifestV1 } from './skill-package-manifest'
|
||||
import type { SkillBundleManifestV1 } from './skill-bundle-manifest'
|
||||
|
||||
export type SkillCloudOptions = {
|
||||
apiUrl?: string
|
||||
@@ -22,7 +23,7 @@ export type SkillCloudVersion = {
|
||||
compressedBytes: number
|
||||
createdAt: string
|
||||
releaseNotes: string
|
||||
manifest: SkillPackageManifestV1
|
||||
manifest: SkillPackageManifestV1 | SkillBundleManifestV1
|
||||
publisher?: { userId: string; organizationId?: string }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export const SKILL_INSTALL_RESULT_V2_CAPABILITY = 'skills.install-result.v2' as
|
||||
export const SKILL_INSTALL_CANCEL_CAPABILITY = 'skills.install-cancel.v1' as const
|
||||
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_UPDATE_REQUIRED_MESSAGE =
|
||||
'Update Orca on the selected machine to install shared skills.'
|
||||
|
||||
@@ -112,6 +112,7 @@ export type ManagedSkillInstall = {
|
||||
packageId: string
|
||||
versionId: string
|
||||
packageDigest: string
|
||||
bundleDigest?: string
|
||||
scope: 'global' | 'workspace'
|
||||
destinationIdentity: string
|
||||
destination: SkillInstallDestination
|
||||
@@ -124,6 +125,10 @@ export const ManagedSkillInstallSchema: z.ZodType<ManagedSkillInstall> = z.objec
|
||||
packageId: z.string(),
|
||||
versionId: z.string(),
|
||||
packageDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
bundleDigest: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
scope: z.enum(['global', 'workspace']),
|
||||
destinationIdentity: z.string(),
|
||||
destination: SkillInstallDestinationSchema,
|
||||
|
||||
@@ -10,6 +10,12 @@ import type {
|
||||
SkillPackageIdentity,
|
||||
ManagedSkillInstall
|
||||
} from './skill-install-contract'
|
||||
import type {
|
||||
SkillBundleInstallPreview,
|
||||
SkillBundleInstallResult,
|
||||
SkillBundlePackageIdentity,
|
||||
SkillBundleSelectedSkill
|
||||
} from './skill-bundle-install-contract'
|
||||
|
||||
export type SkillSharePreview = {
|
||||
preparationId: string
|
||||
@@ -18,6 +24,17 @@ export type SkillSharePreview = {
|
||||
name: string
|
||||
description: string
|
||||
packageDigest: string
|
||||
skillCount?: number
|
||||
skills?: {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
digest: string
|
||||
fileCount: number
|
||||
totalBytes: number
|
||||
scriptPaths: string[]
|
||||
executablePaths: string[]
|
||||
}[]
|
||||
archiveSha256: string
|
||||
fileCount: number
|
||||
totalBytes: number
|
||||
@@ -73,6 +90,44 @@ export type SkillShareInstallOperation =
|
||||
| { status: 'reconnect-required' }
|
||||
| { status: 'unsupported'; message: string }
|
||||
|
||||
export type SkillBundleShareInstallInput = {
|
||||
shareId: string
|
||||
operationId?: string
|
||||
versionId?: string
|
||||
environmentId?: string
|
||||
selectedSkillIds: string[]
|
||||
destination: SkillInstallDestination
|
||||
conflictDecisions?: {
|
||||
skillId: string
|
||||
resolution: 'keep-local' | 'replace-unmodified' | 'replace-and-discard-local'
|
||||
}[]
|
||||
}
|
||||
|
||||
export type SkillBundlePackageVersionInstallInput = Omit<
|
||||
SkillBundleShareInstallInput,
|
||||
'shareId'
|
||||
> & {
|
||||
packageId: string
|
||||
versionId: string
|
||||
}
|
||||
|
||||
export type SkillBundleShareInstallOperation =
|
||||
| { status: 'ok'; value: SkillBundleInstallResult }
|
||||
| { status: 'unconfigured'; message: string }
|
||||
| { status: 'reconnect-required' }
|
||||
| { status: 'unsupported'; message: string }
|
||||
|
||||
export type SkillBundleInstallPreviewInput = {
|
||||
environmentId?: string
|
||||
package: SkillBundlePackageIdentity
|
||||
selectedSkills: SkillBundleSelectedSkill[]
|
||||
destination: SkillInstallDestination
|
||||
}
|
||||
|
||||
export type SkillBundleInstallPreviewOperation =
|
||||
| { status: 'ok'; value: SkillBundleInstallPreview }
|
||||
| { status: 'unsupported'; message: string }
|
||||
|
||||
export type SkillInstallPreviewInput = {
|
||||
environmentId?: string
|
||||
package: SkillPackageIdentity
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
SkillInstallRequestSchema,
|
||||
SkillRemoveRequestSchema
|
||||
} from './skill-install-contract'
|
||||
import { SkillBundleInstallRequestSchema } from './skill-bundle-install-contract'
|
||||
|
||||
export const SKILL_SSH_RELAY_INSTALL_METHOD = 'skills.install' as const
|
||||
export const SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD = 'skills.installBundle' as const
|
||||
export const SKILL_SSH_RELAY_PREVIEW_METHOD = 'skills.previewInstall' as const
|
||||
export const SKILL_SSH_RELAY_REMOVE_METHOD = 'skills.removeInstall' as const
|
||||
export const SKILL_SSH_RELAY_LIST_METHOD = 'skills.listManagedInstalls' as const
|
||||
@@ -28,6 +30,13 @@ export const SkillSshInstallParamsSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const SkillSshInstallBundleParamsSchema = z
|
||||
.object({
|
||||
request: SkillBundleInstallRequestSchema,
|
||||
workspace: SkillSshWorkspaceAuthoritySchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const SkillSshPreviewParamsSchema = z
|
||||
.object({
|
||||
request: SkillInstallPreviewRequestSchema,
|
||||
@@ -48,5 +57,6 @@ export const SkillSshListParamsSchema = z
|
||||
|
||||
export type SkillSshWorkspaceAuthority = z.infer<typeof SkillSshWorkspaceAuthoritySchema>
|
||||
export type SkillSshInstallParams = z.infer<typeof SkillSshInstallParamsSchema>
|
||||
export type SkillSshInstallBundleParams = z.infer<typeof SkillSshInstallBundleParamsSchema>
|
||||
export type SkillSshPreviewParams = z.infer<typeof SkillSshPreviewParamsSchema>
|
||||
export type SkillSshRemoveParams = z.infer<typeof SkillSshRemoveParamsSchema>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
import { SkillPackageIdentitySchema } from './skill-install-contract'
|
||||
import { SkillBundlePackageIdentitySchema } from './skill-bundle-install-contract'
|
||||
|
||||
export const SKILL_UPLOAD_CHUNK_MAX_BYTES = 256 * 1024
|
||||
|
||||
export const SkillUploadBeginRequestSchema = z
|
||||
.object({
|
||||
package: SkillPackageIdentitySchema,
|
||||
package: z.union([SkillPackageIdentitySchema, SkillBundlePackageIdentitySchema]),
|
||||
transferId: z.string().min(1).max(128).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { SkillCloudPublishResult } from '../../src/shared/skill-cloud-contract'
|
||||
import type { SkillSharePreview } from '../../src/shared/skill-sharing-contract'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
|
||||
const RUN_STAGING = process.env.ORCA_E2E_SKILL_STAGING === '1'
|
||||
const SKILL_NAME = `orca-staging-${randomUUID().slice(0, 8)}`
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: {
|
||||
ORCA_ARTIFACTS_API_URL: 'https://cloud-api-staging.onorca.dev',
|
||||
ORCA_CLOUD_API_URL: 'https://auth-staging.onorca.dev',
|
||||
ORCA_CLOUD_AUTH_URL: 'https://auth-staging.onorca.dev',
|
||||
ORCA_CLOUD_CLIENT_ID: 'orca-desktop'
|
||||
}
|
||||
})
|
||||
|
||||
test.skip(!RUN_STAGING, 'Set ORCA_E2E_SKILL_STAGING=1 to run the live staging journey.')
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('publishes, updates, revokes, and deletes without losing local state', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
test.setTimeout(12 * 60_000)
|
||||
const sourceRoot = mkdtempSync(join(tmpdir(), 'orca-staging-skill-source-'))
|
||||
const source = join(sourceRoot, '.agents', 'skills', SKILL_NAME)
|
||||
const home = await electronApp.evaluate(({ app }) => app.getPath('home'))
|
||||
const globalSkill = join(home, '.agents', 'skills', SKILL_NAME)
|
||||
let packageId: string | null = null
|
||||
try {
|
||||
mkdirSync(source, { recursive: true })
|
||||
writeSkill(source, 'v1')
|
||||
await connectCloud(orcaPage)
|
||||
|
||||
const first = await publish(
|
||||
orcaPage,
|
||||
sourceRoot,
|
||||
'Initial staging journey',
|
||||
undefined,
|
||||
(preview) => {
|
||||
packageId = preview.packageId
|
||||
}
|
||||
)
|
||||
const firstInstall = await installVersion(orcaPage, first.published, { scope: 'global' })
|
||||
expect(firstInstall).toMatchObject({ status: 'ok', value: { status: 'installed' } })
|
||||
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: v1')
|
||||
|
||||
writeSkill(globalSkill, 'local')
|
||||
writeSkill(source, 'v2')
|
||||
const second = await publish(
|
||||
orcaPage,
|
||||
sourceRoot,
|
||||
'Second immutable version',
|
||||
first.preview.packageId
|
||||
)
|
||||
const conflict = await installVersion(orcaPage, second.published, { scope: 'global' })
|
||||
expect(conflict).toMatchObject({
|
||||
status: 'ok',
|
||||
value: { status: 'conflict', conflict: { kind: 'modified' } }
|
||||
})
|
||||
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: local')
|
||||
|
||||
const update = await installVersion(
|
||||
orcaPage,
|
||||
second.published,
|
||||
{ scope: 'global' },
|
||||
'replace-and-discard-local'
|
||||
)
|
||||
expect(update).toMatchObject({ status: 'ok', value: { status: 'updated' } })
|
||||
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: v2')
|
||||
|
||||
const rollback = await installVersion(
|
||||
orcaPage,
|
||||
first.published,
|
||||
{ scope: 'global' },
|
||||
'replace-unmodified'
|
||||
)
|
||||
expect(rollback).toMatchObject({ status: 'ok', value: { status: 'updated' } })
|
||||
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: v1')
|
||||
|
||||
expect(
|
||||
await orcaPage.evaluate(
|
||||
(shareId) => window.api.skills.revokeShare(shareId),
|
||||
first.published.share.id
|
||||
)
|
||||
).toMatchObject({ status: 'ok' })
|
||||
const revokedInstall = await orcaPage.evaluate(
|
||||
async ({ shareId }) => {
|
||||
try {
|
||||
return await window.api.skills.installShare({ shareId, destination: { scope: 'global' } })
|
||||
} catch {
|
||||
return { status: 'rejected' as const }
|
||||
}
|
||||
},
|
||||
{ shareId: first.published.share.id }
|
||||
)
|
||||
expect(revokedInstall.status).toBe('rejected')
|
||||
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: v1')
|
||||
|
||||
const removed = await orcaPage.evaluate(
|
||||
(name) => window.api.skills.removeInstall({ name, destination: { scope: 'global' } }),
|
||||
SKILL_NAME
|
||||
)
|
||||
expect(removed).toMatchObject({ status: 'ok', value: { status: 'removed' } })
|
||||
expect(existsSync(globalSkill)).toBe(false)
|
||||
expect(
|
||||
await orcaPage.evaluate((id) => window.api.skills.getPackage(id), packageId)
|
||||
).toMatchObject({ status: 'ok' })
|
||||
expect(
|
||||
await orcaPage.evaluate((id) => window.api.skills.deletePackage(id), packageId)
|
||||
).toMatchObject({ status: 'ok' })
|
||||
packageId = null
|
||||
} finally {
|
||||
if (packageId) {
|
||||
await orcaPage
|
||||
.evaluate((id) => window.api.skills.deletePackage(id), packageId)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
rmSync(globalSkill, { recursive: true, force: true })
|
||||
rmSync(sourceRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function writeSkill(directory: string, version: string): void {
|
||||
writeFileSync(
|
||||
join(directory, 'SKILL.md'),
|
||||
`---\nname: ${SKILL_NAME}\ndescription: Orca staging installation journey\n---\n\n# Staging journey\n\nversion: ${version}\n`
|
||||
)
|
||||
}
|
||||
|
||||
async function connectCloud(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(() => window.api.orcaProfiles.connectCurrent())
|
||||
expect(result.status).toBe('connected')
|
||||
}
|
||||
|
||||
async function publish(
|
||||
page: Page,
|
||||
cwd: string,
|
||||
releaseNotes: string,
|
||||
packageId?: string,
|
||||
onPrepared?: (preview: SkillSharePreview) => void
|
||||
): Promise<{ preview: SkillSharePreview; published: SkillCloudPublishResult }> {
|
||||
const sourceDirectory = join(cwd, '.agents', 'skills', SKILL_NAME)
|
||||
const skill = await page.evaluate(
|
||||
async ({ cwd, sourceDirectory }) =>
|
||||
(await window.api.skills.discover({ cwd })).skills.find(
|
||||
(candidate) => candidate.directoryPath === sourceDirectory
|
||||
),
|
||||
{ cwd, sourceDirectory }
|
||||
)
|
||||
if (!skill) {
|
||||
throw new Error('staging skill source was not discovered')
|
||||
}
|
||||
const preview = await page.evaluate(
|
||||
({ skillId, cwd, packageId }) =>
|
||||
window.api.skills.prepareShare({
|
||||
skillId,
|
||||
target: { cwd },
|
||||
...(packageId ? { packageId } : {})
|
||||
}),
|
||||
{ skillId: skill.id, cwd, packageId }
|
||||
)
|
||||
onPrepared?.(preview)
|
||||
const operation = await page.evaluate(
|
||||
async ({ preparationId, releaseNotes }) => {
|
||||
const auth = await window.api.orcaProfiles.authStatus()
|
||||
const userId = auth.cloud?.userId
|
||||
if (!userId) {
|
||||
throw new Error('staging user identity unavailable')
|
||||
}
|
||||
return window.api.skills.publishShare({
|
||||
preparationId,
|
||||
releaseNotes,
|
||||
userIds: [userId],
|
||||
shareWithOrganization: false
|
||||
})
|
||||
},
|
||||
{ preparationId: preview.preparationId, releaseNotes }
|
||||
)
|
||||
expect(operation.status).toBe('ok')
|
||||
if (operation.status !== 'ok') {
|
||||
throw new Error(`staging publish failed: ${operation.status}`)
|
||||
}
|
||||
return { preview, published: operation.value }
|
||||
}
|
||||
|
||||
function installVersion(
|
||||
page: Page,
|
||||
published: SkillCloudPublishResult,
|
||||
destination: { scope: 'global' },
|
||||
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local'
|
||||
) {
|
||||
return page.evaluate(
|
||||
({ packageId, versionId, destination, conflictResolution }) =>
|
||||
window.api.skills.installPackageVersion({
|
||||
packageId,
|
||||
versionId,
|
||||
destination,
|
||||
...(conflictResolution ? { conflictResolution } : {})
|
||||
}),
|
||||
{
|
||||
packageId: published.version.packageId,
|
||||
versionId: published.version.versionId,
|
||||
destination,
|
||||
conflictResolution
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user