fix(skills): judge official skill updates by ownership, not snapshot matching (#27603)

* fix(skills): judge official skill updates by ownership, not snapshot matching

Orca decided whether an installed official skill was pristine by matching its
bytes against a shipped list of past snapshots, falling back to the skills CLI's
lock hash. Copies installed between releases, CRLF copies, and installs whose lock
recorded a sha256 hash all read "doesn't match the official version" and could not
be updated (#26841), and older content could be labelled newer and never offered.

Now an official skill is Orca's when the skills.sh global lock says it came from
stablyai/orca, and it is current only when its listed files equal the bundled
manifest (line endings and sidecars ignored); anything else is outdated. Install
and update are one pinned `skills add .../tree/v<appVersion>` command, used by the
in-app runner, `orca skills update`, and the Settings rails. The update dialog
preselects Orca-owned rows and lists same-named skills from elsewhere unchecked.

Removes the snapshot registry, release mapping, git-tree identity, lock trust and
convergence logic, and the release-cut provenance step.

* ci(skills): run the pinned-add round trip on a skills CLI that can fetch a commit

* fix(skills): let orca skills update target the shared folder on a bare host

Update became the same pinned add as install, so it inherited install's
refusal when no agent is detected. That broke the bare-host update the old
command guaranteed, and the built-CLI check (update --dry-run, no --agent) on
CI runners. The in-app runner already fell back to universal; both now share
one detectSkillsCliUpdateAgentKeys rule.

Also brings the CLI skills docs off `npx skills update`.

* refactor(skills): derive the update selection from the grouped rows

The selection hook took the live and the retained inventory, re-derived Orca
ownership from installations beside the grouping's own orcaOwned, and kept a
separate display count. Groups already carry both eligibility and ownership,
so the hook reads them once; the button's existing live-scan gate is the only
authorization check. The unselected line is inlined into the headline.

* refactor(skills): drop helpers the ownership design made redundant

- isOwnerManagedSkillScope only negated into the participation check once
  plugin caches stopped participating; fold it in.
- Eligibility re-checked resolvedPath/physicalIdentity, which observation
  already turns into 'inaccessible'.
- The manifest verifier probed access() before a readFile that throws anyway.
- orchestration-install-command.ts only forwarded one constant from the lib
  module its three importers already import.

* i18n(skills): stop naming npx skills update in four skipped-copy strings

The es/ja/ko/zh cantUpdateReason and tipDuplicate strings still named
`npx skills update`, which Orca no longer runs for its skills. Say 'the update
command' instead.

* refactor(skills): inline the single-use pinned source URL helper

orcaSkillsSourceUrl was exported but only called by the builder beside it.

* fix(skills): let an update choice lapse when the install source changes

The selection froze every checked name, Orca's preselection included, the
moment the user ticked anything. A re-check that then attributed a name to
another source kept it checked, replacing it without consent. Store only the
user's explicit choices, keyed by name and lock source, and default to
ownership otherwise.

* fix(skills): label the empty update action 'Update skills', not 'Update 0 skills'

The button stays whenever a row is updatable, so unticking every row (or a
dialog with only non-Orca copies outdated) doesn't reflow the footer. At zero
selected it stays disabled and reads 'Update skills' instead of a zero count.
The label lives beside the dialog's other count copy.

* fix(skills): let keyboard focus open the Not from Orca tooltip

The source of a non-Orca copy is only in the tooltip, and its trigger was a
plain span. Make it focusable like Orca's other tooltip-on-text triggers
(tabIndex 0, cursor-help, the row primitive's focus ring), as in the agreed
mock.

* test(skills): hold the round-trip add equal to the shared install builder

The CI round trip hardcoded its skills add argv, so it could drift from what
Orca runs. A .mjs script can't import the TS builder (extensionless imports),
so the argv moves into an exported roundTripAddArgs and a unit test asserts it
is the builder's unattended add with the tag swapped for the PR commit. The
script runs under the same main guard as the manifest generator.

* i18n(skills): translate the skill row's location count and duplicate reason

es, ko and zh still carried English for SkillUpdateRow.oneLocation and
manyLocations, and es and ko for SkillFreshnessRow.skippedReasonDuplicate.

* fix(skills): pin official skill installs to the build commit and skills CLI

`skills add .../tree/v<version>` only works for stable and RC builds: hourly,
daily and adhoc builds publish elsewhere, so stablyai/orca has no tag for their
version and both install and update failed there. Every build is made from a
commit that exists in stablyai/orca, so the build now stamps `git rev-parse HEAD`
and the one shared builder pins to `tree/<commit>`, which equals the bundled
skills by construction. Main, renderer, web and vitest get it as
ORCA_BUILD_COMMIT (replacing ORCA_APP_VERSION); the tsc-built CLI reads it from
the compiled out/package.json boundary. A missing or malformed commit fails the
build or the command with a clear error instead of building a bad URL.

The builder also pins the skills CLI (`npx skills@1.7.2`): 1.5.x cannot fetch a
commit, and a bare `npx skills` can reuse a stale cache or resolve to another
binary (#18687). The CI round-trip script reuses the same constant.

* ci(skills): run the round trip on the shared pinned skills CLI version

* docs(skills): document the commit-pinned skills add

* refactor(skills): keep the pinned skills CLI version in the install builder

skills-cli-version.ts existed only so the plain-Node round-trip script could
strip-load a .ts file, which needed --disable-warning in CI. The version now
lives beside the builder that uses it; the script keeps a literal, and the
existing round-trip test already asserts its argv, skills@<version> included,
equals the builder's.

* refactor(skills): validate the build commit once, where it is resolved

resolveBuildSourceCommit already refuses anything but a full hash, and both the
define and the CLI's stamped package.json come from it. The builder's second
check could only fire as a module-load throw in the renderer's command
constants. Also drops the resolver's unused cwd parameter.

* test: stamp a fixed build commit instead of running git in the vitest config

Tests only interpolate ORCA_BUILD_COMMIT, so they need not read HEAD (or fail
outside a git checkout) on every run.

* refactor(settings): match only skills add in the Windows npx preflight

Every official skill command is now the builder's skills@<version> add; no
rail emits skills update any more.

* fix(skills): fail an update that leaves a broken skill link behind

Eligibility already skips names with a broken link before the run, so one
afterwards is the run's own damage; report it instead of claiming success.

* ci(release): refuse to tag when the skill manifest is stale

The removed --release step implicitly checked the committed manifest against
skills/. Keep that guarantee with a verify-only run; it writes nothing.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
OrcaWin
2026-10-11 18:30:03 -07:00
committed by GitHub
co-authored by Orca Worker
parent 4526d8bab4
commit 83056e5edb
101 changed files with 2069 additions and 6931 deletions
+9 -15
View File
@@ -732,19 +732,13 @@ jobs:
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
# assumptions and any lifecycle scripts that would run on bump).
npm version "$VERSION" --no-git-tag-version --allow-same-version
# Why: the cut is the only point where committed skill bytes become a
# released revision. Without this row the ledger never advances, so the
# next skill change rebuilds the revision this tag ships over different
# bytes and every install of it stops matching a known snapshot.
# --release is provenance-only: it fails if the content-addressed
# artifacts do not already match this ref and writes just the mapping
# row, so the version commit stays skill-independent. Node built-ins
# only, so this needs no install.
if ! node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"; then
echo "::error::Refusing to record release provenance for v$VERSION: the committed skill artifacts do not match this ref. Land a regeneration on main, then re-run the cut." >&2
# Why: freshness compares installs against the bundled manifest, so a tag must
# never ship skill bytes it doesn't describe. Node built-ins only; no install.
if ! node config/scripts/generate-skill-bundle-manifest.mjs; then
echo "::error::Refusing to tag v$VERSION: the committed skill manifest does not match skills/. Land a regeneration on main, then re-run the cut." >&2
exit 1
fi
git add package.json resources/skills/release-mapping.json
git add package.json
commit_message="release: v$VERSION"
if [[ "$EVENT_NAME" == "schedule" ]]; then
commit_message="$commit_message [rc-slot:$SLOT]"
@@ -761,16 +755,16 @@ jobs:
# var, a composite action, or concatenation, and `git commit` has forms
# (-a, -i, --only, a pathspec) that commit the working tree rather than
# the index. Assert what the commit actually carries, so the tag can
# only ever ship the version bump and the provenance row, no matter
# which step staged what or how the commit was spelled.
# only ever ship the version bump, no matter which step staged what or
# how the commit was spelled.
# -F because the allowlist is literal: unanchored, `.` would match any
# character and quietly admit a path like `packageXjson`.
# -m --first-parent: plain diff-tree prints NOTHING for a merge commit,
# which would make this guard pass silently rather than fail closed.
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
grep -vxF -e 'package.json' -e 'resources/skills/release-mapping.json' || true)"
grep -vxF -e 'package.json' || true)"
if [[ -n "$committed" ]]; then
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json and the skill release-mapping row may ship in a version commit." >&2
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json may ship in a version commit." >&2
exit 1
fi
git tag -a "v$VERSION" -m "v$VERSION"
+7 -7
View File
@@ -11,6 +11,7 @@ on:
- 'resources/skills/**'
- 'config/scripts/verify-skill-update-roundtrip.mjs'
- 'src/main/skills/skill-freshness-eligibility.ts'
- 'src/shared/agent-feature-install-commands.ts'
- 'src/shared/skill-freshness.ts'
- '.github/workflows/skill-update-roundtrip.yml'
# Why unfiltered: GitHub ignores `paths` on merge_group. We do not run a merge
@@ -33,7 +34,8 @@ jobs:
os: [macos-latest, ubuntu-latest, windows-latest]
shape: [symlink, copy]
autocrlf: ['false', 'true']
skills-cli: ['1.5.17']
# Why: `pinned` runs the skills CLI version Orca ships (SKILLS_CLI_VERSION).
skills-cli: [pinned]
include:
- os: ubuntu-latest
shape: symlink
@@ -44,20 +46,18 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
# Historical skill snapshots need tags, but only their blobs are read.
fetch-depth: 0
filter: blob:none
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Verify targeted update convergence and copy behavior
- name: Verify the pinned add replaces stale copies
env:
GITHUB_TOKEN: ${{ github.token }}
SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }}
# A commit, the same kind of ref Orca pins official skills to.
SKILL_UPDATE_REF: ${{ github.event.pull_request.head.sha || github.sha }}
run: >-
node config/scripts/verify-skill-update-roundtrip.mjs
--cli=${{ matrix.skills-cli }}
${{ matrix.skills-cli == 'latest' && '--cli=latest' || '' }}
--autocrlf=${{ matrix.autocrlf }}
--shape=${{ matrix.shape }}
+22
View File
@@ -0,0 +1,22 @@
import { execFileSync } from 'node:child_process'
/**
* The full commit this build is made from. Official skill installs pin to it, so the
* skills an Orca build installs are the ones it bundles on every release channel.
*/
export function resolveBuildSourceCommit() {
let commit = ''
try {
commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
} catch (error) {
throw new Error(
`Could not read the source commit with \`git rev-parse HEAD\`: ${
error instanceof Error ? error.message : String(error)
}. Orca builds must run from a git checkout so official skills can pin to it.`
)
}
if (!/^[0-9a-f]{40}$/.test(commit)) {
throw new Error(`\`git rev-parse HEAD\` returned "${commit}", not a full commit hash.`)
}
return commit
}
+27 -516
View File
@@ -1,27 +1,16 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { constants } from 'node:fs'
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { isDeepStrictEqual } from 'node:util'
// Version artifacts independently to preserve the registry's schema-gated append-only guard.
const CURRENT_MANIFEST_SCHEMA_VERSION = 2
const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1
const RELEASE_MAPPING_SCHEMA_VERSION = 1
// Why: schema 3 dropped release revisions and git tree identity along with the snapshot
// registry. Freshness compares installed copies against these bytes only.
const CURRENT_MANIFEST_SCHEMA_VERSION = 3
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
// Why: the manifest and registry are content-addressed — they describe skill
// bytes. The mapping is provenance: which already-committed revision a tag
// shipped. A release cut may append the second without regenerating the first.
const CONTENT_ADDRESSED_PATHS = [CURRENT_MANIFEST_PATH, SNAPSHOT_REGISTRY_PATH]
const ALL_ARTIFACT_PATHS = [...CONTENT_ADDRESSED_PATHS, RELEASE_MAPPING_PATH]
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
@@ -31,10 +20,6 @@ function compareCodeUnits(left, right) {
return left === right ? 0 : left < right ? -1 : 1
}
function gitObjectSha(kind, bytes) {
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
}
function normalizeText(bytes) {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
@@ -74,56 +59,10 @@ function describeFile(manifestPath, bytes, executable) {
classification: normalized === null ? 'binary' : 'text',
exactSha256,
textNormalizedSha256,
identitySha256: normalized !== null && !executable ? textNormalizedSha256 : exactSha256,
gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
identitySha256: normalized !== null && !executable ? textNormalizedSha256 : exactSha256
}
}
function gitTreeSha(entries) {
const root = { directories: new Map(), files: [] }
for (const entry of entries) {
const parts = entry.path.split('/')
const filename = parts.pop()
let directory = root
for (const part of parts) {
let child = directory.directories.get(part)
if (!child) {
child = { directories: new Map(), files: [] }
directory.directories.set(part, child)
}
directory = child
}
directory.files.push({ filename, ...entry })
}
function hashDirectory(directory) {
const children = [
...[...directory.directories].map(([name, child]) => ({
mode: '40000',
name,
hash: hashDirectory(child)
})),
...directory.files.map((file) => ({
mode: file.executable ? '100755' : '100644',
name: file.filename,
hash: Buffer.from(file.gitBlobSha, 'hex')
}))
].sort((left, right) => {
const leftName = left.mode === '40000' ? `${left.name}/` : left.name
const rightName = right.mode === '40000' ? `${right.name}/` : right.name
return Buffer.from(leftName).compare(Buffer.from(rightName))
})
const body = Buffer.concat(
children.map(({ mode, name, hash }) =>
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
)
)
return gitObjectSha('tree', body)
}
return hashDirectory(root).toString('hex')
}
// Why: kept in step with isOsMetadataSkillEntryName in src/main/skills/skill-package-identity.ts.
// The scanner ignores these because the OS writes them into a live install; the generator
// ignores them so a stray one in a working tree cannot be committed into the manifest as
@@ -175,7 +114,7 @@ async function collectPackageFiles(packageRoot) {
throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
}
// Why: Windows observation cannot see execute bits, so an executable file in
// a shipped skill would misclassify every pristine Windows install as unrecognized.
// a shipped skill would misclassify every pristine Windows install as outdated.
if ((fileStat.mode & 0o111) !== 0) {
throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`)
}
@@ -187,92 +126,8 @@ async function collectPackageFiles(packageRoot) {
return sortManifestFiles(files)
}
function collectGitSkillTreeEntries(treeSha) {
const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha])
.toString('utf8')
.split('\0')
.filter(Boolean)
const packages = new Map()
for (const line of output) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
if (!match) {
throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`)
}
const [, mode, type, objectSha, sourcePath] = match
const separator = sourcePath.indexOf('/')
if (separator <= 0 || separator === sourcePath.length - 1) {
throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`)
}
const name = sourcePath.slice(0, separator)
const manifestPath = sourcePath.slice(separator + 1)
const entries = packages.get(name) ?? []
entries.push({ mode, type, objectSha, manifestPath })
packages.set(name, entries)
}
return packages
}
function readGitBlobs(objectShas) {
const uniqueShas = [...new Set(objectShas)]
if (uniqueShas.length === 0) {
return new Map()
}
// Why: released history spans hundreds of tags. Batch mode avoids a Git
// subprocess per historical file while remaining available on Git 2.25.
const output = execFileSync('git', ['cat-file', '--batch'], {
input: `${uniqueShas.join('\n')}\n`,
maxBuffer: 64 * 1024 * 1024
})
const blobs = new Map()
let offset = 0
for (const requestedSha of uniqueShas) {
const headerEnd = output.indexOf(10, offset)
if (headerEnd === -1) {
throw new Error(`Missing git cat-file header for ${requestedSha}`)
}
const header = output.subarray(offset, headerEnd).toString('utf8')
const match = /^([a-f0-9]+) blob (\d+)$/.exec(header)
if (!match || match[1] !== requestedSha) {
throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`)
}
const size = Number(match[2])
const contentStart = headerEnd + 1
const contentEnd = contentStart + size
if (contentEnd >= output.length || output[contentEnd] !== 10) {
throw new Error(`Truncated git blob for ${requestedSha}`)
}
blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd)))
offset = contentEnd + 1
}
return blobs
}
function collectGitPackageFiles(treeSha, name, entries, blobs) {
const caseFoldedPaths = new Map()
const files = entries.map(({ mode, type, objectSha, manifestPath }) => {
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`)
}
assertSafeRelativePath(manifestPath)
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const bytes = blobs.get(objectSha)
if (!bytes) {
throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`)
}
return describeFile(manifestPath, bytes, mode === '100755')
})
// Why: git ls-tree emits git byte-order, not the canonical walk order.
return sortManifestFiles(files)
}
// Why: snapshot matching compares files by array index, so every producer —
// working-tree walk, git history, and runtime observation — must emit one
// canonical order. This mirrors the sorted depth-first filesystem walk.
// Why: the package digest hashes files in array order, so the generator and runtime
// observation must emit one canonical order: the sorted depth-first filesystem walk.
function compareManifestPaths(left, right) {
const leftParts = left.split('/')
const rightParts = right.split('/')
@@ -306,380 +161,44 @@ function packageDigest(files) {
)
}
function releaseTags() {
return execFileSync(
'git',
['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
{ encoding: 'utf8' }
)
.split('\n')
.filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
}
function skillsTreeShasAtRefs(refs) {
if (refs.length === 0) {
return []
}
const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], {
input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`,
encoding: 'utf8'
})
const lines = output.trimEnd().split('\n')
if (lines.length !== refs.length) {
throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`)
}
return lines.map((line, index) => {
if (line.endsWith(' missing')) {
return null
}
const match = /^([a-f0-9]+) tree$/.exec(line)
if (!match) {
throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`)
}
return match[1]
})
}
function buildReleasedHistory() {
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
const tags = releaseTags()
const treeShas = skillsTreeShasAtRefs(tags)
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
const packagesByTree = new Map(
distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)])
)
const blobs = readGitBlobs(
[...packagesByTree.values()].flatMap((packages) =>
[...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha))
)
)
let previousSkillsTreeSha = null
for (const [index, tag] of tags.entries()) {
const skillsTreeSha = treeShas[index]
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
continue
}
previousSkillsTreeSha = skillsTreeSha
const revisions = {}
const packages = packagesByTree.get(skillsTreeSha)
if (!packages) {
throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`)
}
for (const name of [...packages.keys()].sort(compareCodeUnits)) {
const entries = packages.get(name)
const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs)
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
continue
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshots.push({
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
})
registry.skills[name] = snapshots
}
revisions[name] = snapshots.at(-1).releaseRevision
}
if (Object.keys(revisions).length > 0) {
mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
}
}
return { registry, mapping }
}
// Why: released history is authoritative committed data, advanced only at
// release cut. Seeding generation from the committed registry + mapping makes
// ordinary verify/regeneration a pure function of working-tree bytes, so it
// never walks git tags — the root cause of recurring lint drift when a clone
// holds stray, deleted, or fork tags the committed artifacts predate.
function releasedHistoryFromCommitted(committedRegistry, committedMapping) {
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
const releasedSnapshotCounts = {}
const mapping =
committedMapping && committedMapping.schemaVersion === RELEASE_MAPPING_SCHEMA_VERSION
? structuredClone(committedMapping)
: { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
if (committedRegistry && committedRegistry.schemaVersion === SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
const mappedCounts = releasedSnapshotCountsFromMapping(mapping)
for (const [name, snapshots] of Object.entries(committedRegistry.skills ?? {})) {
// The committed registry carries at most one unreleased tail beyond the
// revisions named by the mapping; drop it and recompute it from bytes.
const releasedCount = mappedCounts?.[name] ?? Math.max(0, snapshots.length - 1)
registry.skills[name] = snapshots.slice(0, releasedCount)
releasedSnapshotCounts[name] = releasedCount
}
}
return { registry, mapping, releasedSnapshotCounts }
}
// Why: disaster recovery only. Reconstruct released history from the immutable
// release tags when the committed ledger must be rebuilt from scratch. Kept off
// the verify/regenerate path — walking tags there is what coupled lint to the
// executing clone's tag state and broke it on version bumps, new tags, and
// stray/deleted local tags.
function releasedHistoryFromTags() {
const { registry, mapping } = buildReleasedHistory()
const releasedSnapshotCounts = Object.fromEntries(
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
)
return { registry, mapping, releasedSnapshotCounts }
}
// Why: release cut is the single authoritative point where working-tree bytes
// become an immutable released revision. Append one mapping row for the version,
// mirroring the historical dedupe where consecutive identical skill trees share
// the earliest release's row.
function appendReleaseRow(artifacts, version) {
const appVersion = version.startsWith('v') ? version.slice(1) : version
const currentRevisions = {}
for (const skill of artifacts.currentManifest.skills) {
currentRevisions[skill.name] = skill.releaseRevision
}
const releases = artifacts.releaseMapping.releases
const last = releases.at(-1)
if (last && isDeepStrictEqual(last.skills, currentRevisions)) {
return
}
// Why: a cut that pushed the version bump to main but died before pushing the
// tag is re-cut at the same version. If skills changed in between, appending
// would leave two rows claiming this version and the stale one would name
// revisions that tag never ships — overwrite, since the tag ships these bytes.
if (last?.appVersion === appVersion) {
releases[releases.length - 1] = { appVersion, skills: currentRevisions }
return
}
// Why: an earlier row means re-cutting an already-shipped version, which the
// cut workflow refuses upstream. Fail rather than corrupt shipped provenance.
if (releases.some((release) => release.appVersion === appVersion)) {
throw new Error(`Release mapping already has a row for ${appVersion}.`)
}
releases.push({ appVersion, skills: currentRevisions })
}
async function buildArtifacts(releasedHistory) {
const { registry, mapping, releasedSnapshotCounts } = releasedHistory
async function buildCurrentManifest() {
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareCodeUnits)
const currentSkills = []
const skills = []
for (const name of skillDirectories) {
const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
const files = await collectPackageFiles(path.join(SKILLS_ROOT, name))
if (!files.some((file) => file.path === 'SKILL.md')) {
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
let snapshot = latest
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshot = {
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
}
snapshots.push(snapshot)
registry.skills[name] = snapshots
}
currentSkills.push({
name,
sourcePath: `skills/${name}`,
...snapshot
})
}
return {
currentManifest: {
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
skills: currentSkills
},
snapshotRegistry: registry,
releaseMapping: mapping,
releasedSnapshotCounts
}
}
// Why: released snapshots are the detection ground truth for existing installs,
// so a generation-logic change must not rewrite them silently. Only the one
// unreleased working-tree append per skill may change between runs.
function releasedSnapshotCountsFromMapping(releaseMapping) {
if (!releaseMapping || releaseMapping.schemaVersion !== RELEASE_MAPPING_SCHEMA_VERSION) {
return null
}
const counts = {}
for (const release of releaseMapping.releases ?? []) {
for (const [name, revision] of Object.entries(release.skills ?? {})) {
counts[name] = Math.max(counts[name] ?? 0, revision)
}
}
return counts
}
function assertReleasedHistoryPreserved(committedRegistry, artifacts, committedReleaseMapping) {
if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
return
}
const committedReleasedCounts = releasedSnapshotCountsFromMapping(committedReleaseMapping)
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
const minimumReleasedCount =
committedReleasedCounts?.[name] ?? Math.max(0, committedSnapshots.length - 1)
if (releasedCount < minimumReleasedCount) {
throw new Error(
`Released snapshot history is incomplete for ${name}. ` +
'Fetch all release tags before regenerating skill artifacts.'
)
}
const protectedCount = committedReleasedCounts
? (committedReleasedCounts[name] ?? 0)
: Math.min(committedSnapshots.length, releasedCount)
for (let index = 0; index < protectedCount; index += 1) {
const committed = committedSnapshots[index]
const rebuilt = regenerated[index]
if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
throw new Error(
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
'Released snapshots are append-only; a deliberate identity migration must update this check.'
)
}
}
}
}
async function readCommittedRegistry() {
try {
return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8'))
} catch {
return null
}
}
async function readCommittedReleaseMapping() {
try {
return JSON.parse(await readFile(RELEASE_MAPPING_PATH, 'utf8'))
} catch {
return null
skills.push({ name, sourcePath: `skills/${name}`, packageDigest: packageDigest(files), files })
}
return { schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION, skills }
}
function serialized(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
async function writeArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) {
const values = new Map([
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
])
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all(paths.map((filePath) => writeFile(filePath, serialized(values.get(filePath)))))
}
// Why: cutting a release tag adds a trailing mapping row on every checkout at
// once, before any branch can regenerate. A trailing row whose revisions all
// equal the current manifest is provably redundant until the next real
// regeneration (installs of those bytes classify as current and are labeled by
// the running build), so verify must not fail branches over it.
function isToleratedReleaseMappingPrefix(committedText, artifacts) {
let committed
try {
committed = JSON.parse(committedText)
} catch {
return false
}
const derived = artifacts.releaseMapping
const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1
if (committedCount < 0 || committedCount >= derived.releases.length) {
return false
}
const prefix = {
schemaVersion: derived.schemaVersion,
releases: derived.releases.slice(0, committedCount)
}
if (committedText !== serialized(prefix)) {
return false
}
const currentRevisions = Object.fromEntries(
artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision])
)
return derived.releases
.slice(committedCount)
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
}
async function verifyArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) {
const expected = [
[CURRENT_MANIFEST_PATH, artifacts.currentManifest, null],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix]
].filter(([filePath]) => paths.includes(filePath))
const stale = []
for (const [filePath, value, tolerated] of expected) {
try {
await access(filePath, constants.R_OK)
const committedText = await readFile(filePath, 'utf8')
if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) {
stale.push(filePath)
}
} catch {
stale.push(filePath)
}
}
if (stale.length > 0) {
async function verifyCurrentManifest(manifest) {
const committedText = await readFile(CURRENT_MANIFEST_PATH, 'utf8').catch(() => null)
if (committedText !== serialized(manifest)) {
throw new Error(
`Generated skill artifacts are stale:\n${stale
.map((filePath) => path.relative(REPO_ROOT, filePath))
.join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
`Generated skill manifest is stale:\n${path.relative(REPO_ROOT, CURRENT_MANIFEST_PATH)}\n` +
'Run pnpm generate:skill-bundle-manifest.'
)
}
}
async function main() {
const argv = process.argv.slice(2)
const rebuildFromTags = argv.includes('--rebuild-from-tags')
const releaseIndex = argv.indexOf('--release')
const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null
if (releaseIndex !== -1 && !releaseVersion) {
throw new Error('--release requires a version argument, e.g. --release 1.4.160')
const manifest = await buildCurrentManifest()
if (process.argv.slice(2).includes('--write')) {
await mkdir(OUTPUT_ROOT, { recursive: true })
await writeFile(CURRENT_MANIFEST_PATH, serialized(manifest))
return
}
const committedRegistry = await readCommittedRegistry()
const committedMapping = await readCommittedReleaseMapping()
const releasedHistory = rebuildFromTags
? releasedHistoryFromTags()
: releasedHistoryFromCommitted(committedRegistry, committedMapping)
const artifacts = await buildArtifacts(releasedHistory)
if (releaseVersion) {
// Why: the row names revisions the committed registry must already contain,
// so proving those artifacts match this ref is what lets the cut record
// provenance without regenerating them. If regeneration disagrees with what
// is committed, the row would name a revision this tag does not ship.
await verifyArtifacts(artifacts, CONTENT_ADDRESSED_PATHS)
appendReleaseRow(artifacts, releaseVersion)
}
// Why: released snapshots are append-only. The committed registry/mapping are
// read fresh here so the artifacts (which may have appended a release row) can
// never alias what we validate against. This mapping must stay the PRE-append
// one to match artifacts.releasedSnapshotCounts, which seeding fixed before the
// row existed; the post-append mapping names one more revision than the counts
// do, which reads as incomplete history and would throw on every cut.
assertReleasedHistoryPreserved(committedRegistry, artifacts, committedMapping)
const shouldWrite = releaseVersion !== null || argv.includes('--write')
const writePaths = releaseVersion ? [RELEASE_MAPPING_PATH] : ALL_ARTIFACT_PATHS
await (shouldWrite ? writeArtifacts(artifacts, writePaths) : verifyArtifacts(artifacts))
await verifyCurrentManifest(manifest)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
@@ -690,19 +209,11 @@ if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
}
export {
appendReleaseRow,
assertReleasedHistoryPreserved,
buildArtifacts,
buildReleasedHistory,
buildCurrentManifest,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
isToleratedReleaseMappingPrefix,
normalizeText,
packageDigest,
releasedHistoryFromCommitted,
sortManifestFiles,
verifyArtifacts,
writeArtifacts
sortManifestFiles
}
@@ -2,7 +2,6 @@ import { execFileSync } from 'node:child_process'
import {
chmod,
copyFile,
cp,
mkdir,
mkdtemp,
readFile,
@@ -17,16 +16,11 @@ import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { observeSkillPackage } from '../../src/main/skills/skill-package-identity'
import {
appendReleaseRow,
assertReleasedHistoryPreserved,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
isToleratedReleaseMappingPrefix,
normalizeText,
packageDigest,
releasedHistoryFromCommitted,
sortManifestFiles
} from './generate-skill-bundle-manifest.mjs'
@@ -40,9 +34,9 @@ async function createPackage() {
}
// Why: the generator resolves its repo root from its own location, so a copy of
// the script inside a throwaway tree exercises the real CLI — including which
// artifacts each mode is allowed to write — without touching resources/skills.
async function createReleaseSandbox() {
// the script inside a throwaway tree exercises the real CLI without touching
// resources/skills.
async function createGeneratorSandbox() {
// Node resolves the entry point through symlinks, so the script's own
// repo-root check only matches when the sandbox path is already resolved.
const root = await realpath(await createPackage())
@@ -90,7 +84,7 @@ describe('skill bundle manifest generator', () => {
expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/)
})
it('orders git-history files identically to the filesystem walk', async () => {
it('orders files identically however they were listed', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) {
@@ -98,8 +92,8 @@ describe('skill bundle manifest generator', () => {
}
const walked = await collectPackageFiles(packageRoot)
// Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based
// snapshot matching requires history and observation to share one order.
// Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; the digest
// hashes files in array order, so every producer must share one order.
const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) =>
walked.find((file) => file.path === manifestPath)
)
@@ -114,295 +108,18 @@ describe('skill bundle manifest generator', () => {
])
})
it('rejects rewrites of released snapshots and allows floating-tail replacement', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const artifacts = {
releasedSnapshotCounts: { 'orca-cli': 2 },
snapshotRegistry: {
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] }
}
}
expect(() =>
assertReleasedHistoryPreserved(
{ schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } },
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] }
},
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] }
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
}
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
}
},
{ ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
)
).toThrow('Released snapshot history is incomplete for orca-cli')
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
})
it('protects only revisions named by the committed release mapping', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const committedRegistry = {
schemaVersion: 1,
skills: {
'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'unreleased-tail')]
}
}
const artifacts = {
releasedSnapshotCounts: { 'linear-tickets': 2 },
snapshotRegistry: {
schemaVersion: 1,
skills: { 'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'new-release')] }
}
}
expect(() =>
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 1 } }]
})
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 2 } }]
})
).toThrow('Released snapshot history changed for linear-tickets at revision 2')
})
it('tolerates only redundant trailing release-mapping rows', () => {
const serialized = (value) => `${JSON.stringify(value, null, 2)}\n`
const rows = [
{ appVersion: '1.0.0', skills: { 'orca-cli': 1 } },
{ appVersion: '1.1.0', skills: { 'orca-cli': 2 } }
]
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] },
releaseMapping: { schemaVersion: 1, releases: rows }
}
const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] })
// A just-cut tag whose bytes equal the working tree may lag in the mapping.
expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true)
// The committed file matching the derived mapping is byte-equality's job, not tolerance.
expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe(
false
)
// A trailing row for bytes the committed artifacts do not describe is a real gap.
expect(
isToleratedReleaseMappingPrefix(committedPrefix, {
...artifacts,
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] }
})
).toBe(false)
expect(
isToleratedReleaseMappingPrefix(committedPrefix, {
...artifacts,
currentManifest: {
skills: [
{ name: 'orca-cli', releaseRevision: 2 },
{ name: 'orca-linear', releaseRevision: 1 }
]
}
})
).toBe(false)
// Rewritten earlier rows never pass, with or without trailing rows.
expect(
isToleratedReleaseMappingPrefix(
serialized({
schemaVersion: 1,
releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }]
}),
artifacts
)
).toBe(false)
expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false)
expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false)
})
it('seeds released history from the committed ledger and drops the floating tail', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const committedRegistry = {
schemaVersion: 1,
skills: {
// released revs 1..2 named by the mapping, plus an unreleased tail at 3
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'unreleased')],
// no mapping row -> fall back to all-but-tail
'orca-linear': [snapshot(1, 'ccc'), snapshot(2, 'tail')]
}
}
const committedMapping = {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'orca-cli': 2 } }]
}
const seeded = releasedHistoryFromCommitted(committedRegistry, committedMapping)
// The unreleased tail is dropped; only mapping-named revisions survive.
expect(seeded.registry.skills['orca-cli']).toEqual([snapshot(1, 'aaa'), snapshot(2, 'bbb')])
expect(seeded.registry.skills['orca-linear']).toEqual([snapshot(1, 'ccc')])
expect(seeded.releasedSnapshotCounts).toEqual({ 'orca-cli': 2, 'orca-linear': 1 })
// The seed clones the mapping so a later release append cannot alias committed state.
expect(seeded.mapping).toEqual(committedMapping)
expect(seeded.mapping).not.toBe(committedMapping)
})
it('returns an empty ledger when no committed artifacts exist', () => {
const seeded = releasedHistoryFromCommitted(null, null)
expect(seeded.registry.skills).toEqual({})
expect(seeded.releasedSnapshotCounts).toEqual({})
expect(seeded.mapping.releases).toEqual([])
})
it('appends one release row, stripping the v-prefix and deduping identical tails', () => {
const artifacts = {
currentManifest: {
skills: [
{ name: 'orca-cli', releaseRevision: 36 },
{ name: 'orca-linear', releaseRevision: 8 }
]
},
releaseMapping: {
schemaVersion: 1,
releases: [{ appVersion: '1.4.151', skills: { 'orca-cli': 35, 'orca-linear': 8 } }]
}
}
appendReleaseRow(artifacts, 'v1.4.160')
expect(artifacts.releaseMapping.releases.at(-1)).toEqual({
appVersion: '1.4.160',
skills: { 'orca-cli': 36, 'orca-linear': 8 }
})
// A second release over identical revisions adds no row.
appendReleaseRow(artifacts, '1.4.161')
expect(artifacts.releaseMapping.releases).toHaveLength(2)
})
it('overwrites the trailing row when a failed cut is re-cut at the same version', () => {
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] },
releaseMapping: {
schemaVersion: 1,
releases: [
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
// The failed cut already pushed this row to main at revision 36.
{ appVersion: '1.4.160', skills: { 'orca-cli': 36 } }
]
}
}
appendReleaseRow(artifacts, '1.4.160')
// One row per version: the tag ships revision 37, so 36 must not linger.
expect(artifacts.releaseMapping.releases).toEqual([
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
{ appVersion: '1.4.160', skills: { 'orca-cli': 37 } }
it('writes a schema-3 manifest and fails verification once a skill changes', async () => {
const sandbox = await createGeneratorSandbox()
sandbox.generate('--write')
const manifest = JSON.parse(await sandbox.read('current-manifest.json'))
expect(manifest.schemaVersion).toBe(3)
expect(manifest.skills.map((skill) => Object.keys(skill))).toEqual([
['name', 'sourcePath', 'packageDigest', 'files']
])
})
expect(() => sandbox.generate()).not.toThrow()
it('refuses to rewrite an already-shipped version behind the trailing row', () => {
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] },
releaseMapping: {
schemaVersion: 1,
releases: [
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
{ appVersion: '1.4.160', skills: { 'orca-cli': 36 } }
]
}
}
expect(() => appendReleaseRow(artifacts, '1.4.151')).toThrow(/already has a row for 1\.4\.151/)
})
it('records a release without regenerating the content-addressed artifacts', async () => {
const sandbox = await createReleaseSandbox()
sandbox.generate('--write')
const [manifest, registry] = await Promise.all([
sandbox.read('current-manifest.json'),
sandbox.read('snapshot-registry.json')
])
sandbox.generate('--release', 'v1.4.156')
// The cut records provenance for bytes that are already committed, so a
// version-only cut can never rewrite a shipped identity.
expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toEqual([
{ appVersion: '1.4.156', skills: { demo: 1 } }
])
expect(await sandbox.read('current-manifest.json')).toBe(manifest)
expect(await sandbox.read('snapshot-registry.json')).toBe(registry)
// Bytes that changed since the last regeneration would make the row name a
// revision this tag does not ship — refuse rather than record it.
await sandbox.editSkill('edited after the last regeneration\n')
expect(() => sandbox.generate('--release', '1.4.157')).toThrow(
/Generated skill artifacts are stale/
)
expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toHaveLength(1)
})
it('freezes a revision once a release records it, and only until then', async () => {
const sandbox = await createReleaseSandbox()
const demoSnapshots = async () =>
JSON.parse(await sandbox.read('snapshot-registry.json')).skills.demo
sandbox.generate('--write')
const unreleased = (await demoSnapshots())[0].packageDigest
// Nothing has shipped revision 1 yet, so re-deriving it over new bytes is
// correct: the tail floats until a release names it.
await sandbox.editSkill('about to ship\n')
sandbox.generate('--write')
const shipped = await demoSnapshots()
expect(shipped).toHaveLength(1)
expect(shipped[0].packageDigest).not.toBe(unreleased)
sandbox.generate('--release', '1.4.156')
// The cut named revision 1, so the next change appends revision 2 instead of
// rebuilding revision 1. Installs carrying the shipped digest keep matching a
// known snapshot — without the ledger row they would match nothing.
await sandbox.editSkill('changed again after the cut\n')
sandbox.generate('--write')
const frozen = await demoSnapshots()
expect(frozen).toHaveLength(2)
expect(frozen[0]).toEqual(shipped[0])
expect(frozen[1].releaseRevision).toBe(2)
await sandbox.editSkill('changed\n')
expect(() => sandbox.generate()).toThrow()
})
it.runIf(process.platform !== 'win32')(
@@ -522,43 +239,10 @@ describe('skill bundle manifest generator', () => {
expect(generated).toEqual(['.DS_Store.md', '.dsstore', 'SKILL.md', '_SKILL.md', 'ds_store.md'])
})
it('computes the same Git tree identity as Git', async () => {
const packageRoot = await createPackage()
await cp(path.join(REPO_ROOT, 'skills', 'orca-cli'), packageRoot, { recursive: true })
const files = await collectPackageFiles(packageRoot)
// Compare the same bytes even when the skill has uncommitted edits.
execFileSync('git', ['init', '--quiet'], { cwd: packageRoot })
execFileSync('git', ['-c', 'core.autocrlf=false', 'add', '-A'], { cwd: packageRoot })
const expected = execFileSync('git', ['write-tree'], {
cwd: packageRoot,
encoding: 'utf8'
}).trim()
expect(gitTreeSha(files)).toBe(expected)
})
it('matches Git when a directory and file share a name prefix', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n')
await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n')
const files = await collectPackageFiles(packageRoot)
execFileSync('git', ['init', '--quiet'], { cwd: packageRoot })
execFileSync('git', ['add', '-A'], { cwd: packageRoot })
const expected = execFileSync('git', ['write-tree'], {
cwd: packageRoot,
encoding: 'utf8'
}).trim()
expect(gitTreeSha(files)).toBe(expected)
})
// Why: every step in the cut job shares one workspace and one index, so any of
// them can stage the content-addressed artifacts and the bump step's own commit
// then carries them into the tag. Grepping the workflow cannot see a path built
// from an env var, a composite action, or concatenation, so the cut asserts its
// own index before committing; this test pins that guard and adds a tripwire
// for the literal spellings.
// them can stage a regenerated manifest and the bump step's own commit then
// carries it into the tag. The cut asserts what its commit carries; this test
// pins that guard and adds a tripwire for the literal spellings.
it('keeps the whole release-cut job off skill regeneration', async () => {
const workflow = parse(
await readFile(path.join(REPO_ROOT, '.github/workflows/release-cut.yml'), 'utf8')
@@ -568,28 +252,21 @@ describe('skill bundle manifest generator', () => {
.map((step) => ({ name: step.name ?? '(unnamed)', run: step.run.replace(/^\s*#.*$/gm, '') }))
const bumpStep = runSteps.find((step) => step.name === 'Bump package.json and tag')
// The load-bearing check: whatever staged it and however the commit was
// spelled, only these two paths may ship. Asserted on the commit rather than
// the index because `git commit -a/-i/--only/<pathspec>` bypasses the index.
// -F is part of the contract; without it `.` admits a path like packageXjson.
// Flags pinned, not just the command: a `--diff-filter` slipped in here would
// silence modifications, and dropping -m makes a merge commit report nothing.
// Asserted on the commit rather than the index because `git commit -a/-i/--only/
// <pathspec>` bypasses the index. -F is part of the contract; without it `.` admits a
// path like packageXjson. Dropping -m makes a merge commit report nothing.
expect(bumpStep.run).toMatch(
/git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json' -e 'resources\/skills\/release-mapping\.json'/
/git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json'/
)
expect(bumpStep.run.indexOf('grep -vxF')).toBeLessThan(bumpStep.run.indexOf('git tag'))
// ...and that it aborts. A guard degraded to a warning still reads as covered.
// The exit must be inside the guard's own block, not borrowed from a later one.
expect(bumpStep.run).toMatch(
/if \[\[ -n "\$committed" \]\]; then(?:(?!\bfi\b)[\s\S])*exit 1[\s\S]*?fi/
)
// Tripwire only. A step that merely READS this directory may be added here;
// one that writes or stages it must not, and the guard above will reject it.
expect(runSteps.filter((s) => /resources[/\\]skills/.test(s.run)).map((s) => s.name)).toEqual([
'Bump package.json and tag'
])
for (const step of runSteps) {
expect(step.run, step.name).not.toMatch(/--write|generate:skill-bundle-manifest/)
expect(step.run, step.name).not.toMatch(
/resources[/\\]skills|--write|generate:skill-bundle-manifest/
)
}
})
})
@@ -12,8 +12,6 @@ it('keeps byte-compared artifacts and source LF in an autocrlf checkout', () =>
const crlf = 'first\r\nsecond\r\n'
const paths = new Map([
['resources/skills/current-manifest.json', lf],
['resources/skills/snapshot-registry.json', lf],
['resources/skills/release-mapping.json', lf],
['src/main/__snapshots__/example.test.ts.snap', lf],
['src/renderer/src/example.tsx', lf],
['src/main/example.ts', lf],
+12 -4
View File
@@ -4,11 +4,13 @@ import { execFileSync } from 'node:child_process'
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { resolveBuildSourceCommit } from './build-source-commit.mjs'
// Electron packaging restamps the channel-specific version after compilation.
function buildOutPackageJson(version) {
// Electron packaging restamps the channel-specific version after compilation; `commit`
// is what the tsc-compiled CLI pins official skill installs to.
function buildOutPackageJson(version, commit) {
return `${JSON.stringify(
{ name: 'orca-compiled-output', type: 'commonjs', private: true, version },
{ name: 'orca-compiled-output', type: 'commonjs', private: true, version, commit },
null,
2
)}\n`
@@ -22,6 +24,7 @@ export function verifyPackageCliBin({
projectDir = path.resolve(import.meta.dirname, '..', '..'),
fixExecutable = false,
fixPackageJson = false,
sourceCommit,
runHelp = false
} = {}) {
const packageJsonPath = path.join(projectDir, 'package.json')
@@ -48,7 +51,11 @@ export function verifyPackageCliBin({
const outPackageJsonPath = path.join(projectDir, 'out', 'package.json')
if (fixPackageJson) {
mkdirSync(path.dirname(outPackageJsonPath), { recursive: true })
writeFileSync(outPackageJsonPath, buildOutPackageJson(packageJson.version), 'utf8')
writeFileSync(
outPackageJsonPath,
buildOutPackageJson(packageJson.version, sourceCommit),
'utf8'
)
}
let outPackageJson
try {
@@ -93,6 +100,7 @@ function main() {
const result = verifyPackageCliBin({
fixExecutable: args.has('--fix-executable'),
fixPackageJson: args.has('--fix-package-json'),
sourceCommit: args.has('--fix-package-json') ? resolveBuildSourceCommit() : undefined,
runHelp: args.has('--run-help')
})
console.log(
+12
View File
@@ -77,6 +77,18 @@ describe('verifyPackageCliBin', () => {
})
})
it('stamps the build commit the CLI pins official skill installs to', () => {
const { projectDir, outPackageJsonPath } = makeProjectWithCli(
'#!/usr/bin/env node\nconsole.log("orca")\n',
{ writeOutPackageJson: false }
)
verifyPackageCliBin({ projectDir, fixPackageJson: true, sourceCommit: 'c'.repeat(40) })
expect(JSON.parse(readFileSync(outPackageJsonPath, 'utf8')).commit).toBe('c'.repeat(40))
rmSync(projectDir, { recursive: true, force: true })
})
it('rejects a CLI package boundary that is not CommonJS', () => {
const { projectDir, outPackageJsonPath } = makeProjectWithCli(
'#!/usr/bin/env node\nconsole.log("orca")\n'
+149 -218
View File
@@ -1,6 +1,5 @@
import { execFileSync } from 'node:child_process'
import {
chmod,
cp,
lstat,
mkdir,
@@ -20,230 +19,162 @@ function option(name) {
return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
}
const cliVersion = option('cli')
const autocrlf = option('autocrlf')
const placement = option('shape')
// Why: PR branch names are untrusted workflow input. Keep them out of the
// generated shell command and pass them to Node through the environment.
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
if (
!cliVersion ||
(autocrlf !== 'true' && autocrlf !== 'false') ||
(placement !== 'symlink' && placement !== 'copy') ||
!source ||
!ref ||
!/^[^/\s]+\/[^/\s]+$/.test(source)
) {
throw new Error(
'Usage: verify-skill-update-roundtrip.mjs --cli=<version> --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<git-ref>'
)
// Why: the exact rail Orca runs for both install and update, pinned to this PR's commit.
// A unit test holds it, and this pinned skills CLI, equal to the shared builder's argv.
const PINNED_SKILLS_CLI_VERSION = '1.7.2'
export function roundTripAddArgs(source, ref, skillName, cliVersion = PINNED_SKILLS_CLI_VERSION) {
return [
`skills@${cliVersion}`,
'add',
`https://github.com/${source}/tree/${ref}`,
'--skill',
skillName,
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
]
}
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
const home = path.join(sandbox, 'home')
const stateHome = path.join(home, '.state')
const fakeBin = path.join(sandbox, 'bin')
const targetName = 'orca-cli'
const controlName = 'orchestration'
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8'))
const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8'))
function currentSkill(name) {
const skill = manifest.skills.find((entry) => entry.name === name)
if (!skill) {
throw new Error(`Current manifest is missing ${name}`)
}
return skill
}
function historicalRelease(name) {
const current = currentSkill(name)
for (const release of releaseMapping.releases.toReversed()) {
const revision = release.skills[name]
if (typeof revision !== 'number' || revision >= current.releaseRevision) {
continue
}
const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision)
if (snapshot) {
return { tag: `v${release.appVersion}`, snapshot }
}
}
throw new Error(`No historical released snapshot is available for ${name}`)
}
async function materializePackage(name, tag, destination) {
const prefix = `skills/${name}/`
const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`])
.toString('utf8')
.split('\0')
.filter(Boolean)
if (entries.length === 0) {
throw new Error(`${tag} does not contain ${name}`)
}
for (const entry of entries) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry)
if (!match || match[2] !== 'blob') {
throw new Error(`Unsupported historical tree entry: ${entry}`)
}
const relativePath = match[4].slice(prefix.length)
const destinationPath = path.join(destination, ...relativePath.split('/'))
await mkdir(path.dirname(destinationPath), { recursive: true })
await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]]))
if (process.platform !== 'win32' && match[1] === '100755') {
await chmod(destinationPath, 0o755)
}
}
}
async function seedPlacement(name, tag) {
const canonical = path.join(home, '.agents', 'skills', name)
await materializePackage(name, tag, canonical)
const providerRoot = path.join(home, '.claude', 'skills')
const provider = path.join(providerRoot, name)
await mkdir(providerRoot, { recursive: true })
await (placement === 'copy'
? cp(canonical, provider, { recursive: true })
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
}
async function installFakeAgentCommands() {
await mkdir(fakeBin, { recursive: true })
for (const name of ['codex', 'claude']) {
const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name)
await writeFile(
executable,
process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n'
)
if (process.platform !== 'win32') {
await chmod(executable, 0o755)
}
}
}
async function packageDigestAt(pathValue) {
return packageDigest(await collectPackageFiles(pathValue))
}
async function assertCurrentCanonical(name) {
const expected = currentSkill(name).packageDigest
const canonical = path.join(home, '.agents', 'skills', name)
if ((await packageDigestAt(canonical)) !== expected) {
throw new Error(`${name} canonical placement did not update to the PR content`)
}
}
function execSkills(args) {
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
const cliArgs = ['--yes', `skills@${cliVersion}`, ...args]
execFileSync(
executable,
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
{
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, '.codex'),
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
XDG_STATE_HOME: stateHome,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.autocrlf',
GIT_CONFIG_VALUE_0: autocrlf,
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
CI: '1'
},
stdio: 'inherit'
}
)
}
try {
const targetHistorical = historicalRelease(targetName)
const controlHistorical = historicalRelease(controlName)
await installFakeAgentCommands()
await mkdir(path.join(home, '.codex'), { recursive: true })
await mkdir(path.join(home, '.claude'), { recursive: true })
await seedPlacement(targetName, targetHistorical.tag)
await seedPlacement(controlName, controlHistorical.tag)
const targetProvider = path.join(home, '.claude', 'skills', targetName)
const controlCanonical = path.join(home, '.agents', 'skills', controlName)
const controlProvider = path.join(home, '.claude', 'skills', controlName)
const targetProviderBefore = await packageDigestAt(await realpath(targetProvider))
const controlBefore = await packageDigestAt(controlCanonical)
const controlProviderBefore = await packageDigestAt(await realpath(controlProvider))
const timestamp = new Date().toISOString()
const lock = {
version: 3,
skills: {
[targetName]: {
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
ref,
skillPath: `skills/${targetName}/SKILL.md`,
skillFolderHash: targetHistorical.snapshot.gitTreeSha,
installedAt: timestamp,
updatedAt: timestamp
},
[controlName]: {
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
ref,
skillPath: `skills/${controlName}/SKILL.md`,
skillFolderHash: controlHistorical.snapshot.gitTreeSha,
installedAt: timestamp,
updatedAt: timestamp
}
}
}
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
await mkdir(path.dirname(lockPath), { recursive: true })
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
// Why: this is the exact user-visible rail. A bare update would include
// unrelated vendors, while this command must leave the control skill alone.
execSkills(['update', targetName, '--global'])
await assertCurrentCanonical(targetName)
const targetProviderAfter = await packageDigestAt(await realpath(targetProvider))
const targetProviderStat = await lstat(targetProvider)
if (placement === 'symlink' && !targetProviderStat.isSymbolicLink()) {
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
}
if (placement === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) {
throw new Error(`${targetName} provider alias did not converge with the canonical update`)
}
async function main() {
// Why: defaults to the version Orca pins; `--cli=latest` previews the next release.
const cliVersion = option('cli') ?? PINNED_SKILLS_CLI_VERSION
const autocrlf = option('autocrlf')
const placement = option('shape')
// Why: PR refs are untrusted workflow input. Keep them out of the generated shell
// command and pass them to Node through the environment.
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
if (
placement === 'copy' &&
targetProviderAfter !== targetProviderBefore &&
targetProviderAfter !== currentSkill(targetName).packageDigest
(autocrlf !== 'true' && autocrlf !== 'false') ||
(placement !== 'symlink' && placement !== 'copy') ||
!source ||
!ref ||
!/^[^/\s]+\/[^/\s]+$/.test(source) ||
// Why: Orca pins to a full commit, never a branch or tag.
!/^[0-9a-f]{40}$/.test(ref)
) {
throw new Error('Independent provider copy changed to an unexpected package identity')
throw new Error(
'Usage: verify-skill-update-roundtrip.mjs [--cli=<version>] --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<commit>'
)
}
if (placement === 'copy') {
// Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs
// retain the copy. Both prove this input topology must remain ineligible.
const outcome = targetProviderStat.isSymbolicLink()
? 'converged to an alias'
: targetProviderAfter === targetProviderBefore
? 'remained a historical copy'
: 'converged as a copy'
console.log(`[skill-update-roundtrip] independent copy ${outcome}`)
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
const home = path.join(sandbox, 'home')
const stateHome = path.join(home, '.state')
const targetName = 'orca-cli'
const controlName = 'orchestration'
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
function currentDigest(name) {
const skill = manifest.skills.find((entry) => entry.name === name)
if (!skill) {
throw new Error(`Current manifest is missing ${name}`)
}
return skill.packageDigest
}
if ((await packageDigestAt(controlCanonical)) !== controlBefore) {
throw new Error('Targeted update changed the non-targeted control skill')
async function packageDigestAt(directory) {
return packageDigest(await collectPackageFiles(await realpath(directory)))
}
if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) {
throw new Error('Targeted update changed the non-targeted control provider placement')
// Why: the copies #26841 stranded: CRLF bytes from no known release, edited, with a
// sha256 lock hash. The pinned add must replace them all the same.
async function seedStalePlacement(name) {
const canonical = path.join(home, '.agents', 'skills', name)
await mkdir(canonical, { recursive: true })
const markdown = await readFile(path.join('skills', name, 'SKILL.md'), 'utf8')
await writeFile(
path.join(canonical, 'SKILL.md'),
`${markdown}\nA line no Orca release ever shipped.\n`.replace(/\n/g, '\r\n')
)
const providerRoot = path.join(home, '.claude', 'skills')
const provider = path.join(providerRoot, name)
await mkdir(providerRoot, { recursive: true })
await (placement === 'copy'
? cp(canonical, provider, { recursive: true })
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
return { canonical, provider }
}
const controlProviderStat = await lstat(controlProvider)
if (placement === 'symlink' && !controlProviderStat.isSymbolicLink()) {
throw new Error('Targeted update changed the non-targeted control topology')
function execSkills(args) {
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
const cliArgs = ['--yes', ...args]
execFileSync(
executable,
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
{
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, '.codex'),
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
XDG_STATE_HOME: stateHome,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.autocrlf',
GIT_CONFIG_VALUE_0: autocrlf,
CI: '1'
},
stdio: 'inherit'
}
)
}
try {
const target = await seedStalePlacement(targetName)
const control = await seedStalePlacement(controlName)
const controlBefore = await packageDigestAt(control.canonical)
const lock = {
version: 3,
skills: Object.fromEntries(
[targetName, controlName].map((name) => [
name,
{
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
skillPath: `skills/${name}/SKILL.md`,
skillFolderHash: 'f'.repeat(64)
}
])
)
}
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
await mkdir(path.dirname(lockPath), { recursive: true })
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
execSkills(roundTripAddArgs(source, ref, targetName, cliVersion))
const expected = currentDigest(targetName)
if ((await packageDigestAt(target.canonical)) !== expected) {
throw new Error(`${targetName} canonical placement did not become the pinned content`)
}
if ((await packageDigestAt(target.provider)) !== expected) {
throw new Error(`${targetName} claude placement did not become the pinned content`)
}
if (placement === 'symlink' && !(await lstat(target.provider)).isSymbolicLink()) {
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
}
const recorded = JSON.parse(await readFile(lockPath, 'utf8')).skills?.[targetName]?.source
if (typeof recorded !== 'string' || recorded.toLowerCase() !== source.toLowerCase()) {
throw new Error(`${targetName} lock source is ${recorded}, not ${source}`)
}
if ((await packageDigestAt(control.canonical)) !== controlBefore) {
throw new Error('Targeted add changed the non-targeted control skill')
}
if (placement === 'symlink' && !(await lstat(control.provider)).isSymbolicLink()) {
throw new Error('Targeted add changed the non-targeted control topology')
}
} finally {
await rm(sandbox, { recursive: true, force: true })
}
} finally {
await rm(sandbox, { recursive: true, force: true })
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
await main()
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { buildAgentFeatureSkillInstallArgs } from '../../src/shared/agent-feature-install-commands'
import { roundTripAddArgs } from './verify-skill-update-roundtrip.mjs'
const COMMIT = 'a'.repeat(40)
describe('skill update round trip', () => {
it('runs exactly the shared builder’s unattended add, pinned skills CLI included', () => {
expect(roundTripAddArgs('stablyai/orca', COMMIT, 'orca-cli')).toEqual(
buildAgentFeatureSkillInstallArgs(['orca-cli'], {
sourceCommit: COMMIT,
yes: true,
agents: ['claude-code', 'universal']
})
)
})
})
+5 -1
View File
@@ -10,7 +10,11 @@ import { createWorkspaceSourceResolver } from './scripts/workspace-source-export
const balancedShards = process.env.ORCA_BALANCE_UNIT_SHARDS === '1'
const measurementFile = 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts'
const transforms = {
define: { ORCA_FEATURE_WALL_ENABLED: 'true' },
define: {
ORCA_FEATURE_WALL_ENABLED: 'true',
// Why: tests only interpolate it; they must not depend on the checkout's HEAD.
ORCA_BUILD_COMMIT: JSON.stringify('0'.repeat(40))
},
resolve: {
alias: [
...createWorkspaceSourceResolver().aliases,
+6 -6
View File
@@ -53,15 +53,15 @@ Add `--json` when an agent needs deterministic output for automation. `skills sh
When Orca ships a newer skill package than the one installed globally, the app can:
- Show an **update available** nudge (and **Needs attention** when a copy is out of date somewhere the updater cannot safely rewrite).
- Open **Update skills** — list of placements and skip reasons, then run **`npx --yes skills update <names> --global -y`** headlessly (no embedded terminal).
- Show an **update available** nudge for skills Orca installed (and **Needs attention** when a copy is out of date somewhere the updater cannot safely rewrite).
- Open **Update skills** — list of placements and skip reasons, with a checkbox per skill. Orca's own skills start ticked; a same-named skill installed from somewhere else is marked **Not from Orca** and starts unticked. The selected skills are reinstalled headlessly (no embedded terminal) with **`npx --yes skills@1.7.2 add https://github.com/stablyai/orca/tree/<commit> --skill <name>… --global -y`**, pinned to the commit the running Orca build was made from (so it works on hourly, daily, RC and stable builds alike) and to a known skills CLI version.
- Keep the run in the **background**: closing the dialog does not cancel it. A status-bar segment shows progress (spinner while running, brief success check, persistent failure until you act). Click the segment to reopen the dialog.
- Surface freshness on **Settings → Agents** for installed skills (global install rail), including **Details** when a row needs review.
Manual equivalent (desktop Settings still shows the same `npx` form):
Manual equivalent (desktop Settings shows the same pinned form for the running build):
```bash
npx skills update orca-cli orchestration computer-use --global
npx skills@1.7.2 add https://github.com/stablyai/orca/tree/<commit> --skill orca-cli --skill orchestration --global
```
On headless hosts (SSH, containers, CI, `orca serve`) with no Settings UI, use the local CLI wrappers — they resolve the same `npx` commands, add non-interactive flags, and do **not** need a running Orca runtime:
@@ -77,10 +77,10 @@ orca skills update --skill orca-cli --dry-run
- Default scope is **global** (`--global`); pass `--local` for the current project only.
- `install` targets agents Orca detects on the host (plus the shared `.agents/skills` directory). Use `--agent <name>[,<name>…]` or `--agent universal` to override; if no agent is detected, `--agent` is required.
- `update` only refreshes skills that are already installed.
- `update` runs the same pinned `skills add` as `install`. With no agent detected it targets only `.agents/skills` instead of refusing.
- `--dry-run` prints the resolved command; `--json` is only valid with listing / `--dry-run`.
Prefer the in-app updater when Orca offers it so the same global placements the app scanned get rewritten. Rows marked **Skipped** explain why that skill cannot be updated automatically (for example missing source registration) — fix the placement, then re-check.
Prefer the in-app updater when Orca offers it so the same global placements the app scanned get rewritten. Rows marked **Skipped** explain why that skill cannot be updated automatically (for example a read-only folder or a link pointing outside Orca's skill folders) — fix the placement, then re-check.
## Install and delete from the Skills page
+10 -1
View File
@@ -13,6 +13,7 @@ import {
} from './config/build-plugins/plain-node-entry-guard'
import { ORCAD_LOCAL_SERVE_SELECTION_ENTRY } from './src/shared/orcad-local-serve-selection'
import packageJson from './package.json' with { type: 'json' }
import { resolveBuildSourceCommit } from './config/scripts/build-source-commit.mjs'
const BUNDLED_MAIN_DEPENDENCIES = new Set([
'stream-json',
@@ -68,6 +69,10 @@ const ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL =
? JSON.stringify(orcaDiagnosticsTokenUrl)
: 'null'
// Why: official skill installs pin to the commit the build came from, so they match the
// bundled skills on every channel. Main runs the installer; the renderer prints the command.
const ORCA_BUILD_COMMIT_LITERAL = JSON.stringify(resolveBuildSourceCommit())
function createStartupDiagnosticsBanner(chunkName: string): string {
return `
;(() => {
@@ -303,7 +308,8 @@ export const electronViteConfig: UserConfig = {
define: {
ORCA_BUILD_IDENTITY: ORCA_BUILD_IDENTITY_LITERAL,
ORCA_POSTHOG_WRITE_KEY: ORCA_POSTHOG_WRITE_KEY_LITERAL,
ORCA_DIAGNOSTICS_TOKEN_URL: ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL
ORCA_DIAGNOSTICS_TOKEN_URL: ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL,
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL
},
// Why: @xterm/headless declares "exports": null in package.json, which
// prevents Vite's default resolver from finding the CJS entry. Point
@@ -325,6 +331,9 @@ export const electronViteConfig: UserConfig = {
}
},
renderer: {
define: {
ORCA_BUILD_COMMIT: ORCA_BUILD_COMMIT_LITERAL
},
resolve: {
alias: {
...markdownParserAliases,
+1 -17
View File
@@ -1,12 +1,10 @@
{
"schemaVersion": 2,
"schemaVersion": 3,
"skills": [
{
"name": "computer-use",
"sourcePath": "skills/computer-use",
"releaseRevision": 9,
"packageDigest": "425634e3ebf27690cc613eaf17b6337b36769153ec6bca85dc4ebf65d4d6b8b4",
"gitTreeSha": "b59f27370a41225c22127e91da0c91cd2519f217",
"files": [
{
"path": "SKILL.md",
@@ -22,9 +20,7 @@
{
"name": "linear-tickets",
"sourcePath": "skills/linear-tickets",
"releaseRevision": 11,
"packageDigest": "1eab442d048b79ab0b836adf57663ac384988dd79172ec5f14193eb05e037715",
"gitTreeSha": "30d9b40144d4a9a07ce12f0d1a9261fd9cf9649b",
"files": [
{
"path": "SKILL.md",
@@ -40,9 +36,7 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"releaseRevision": 37,
"packageDigest": "0736bcbbb69ed18f9a36a58ad2eda47b6db55b30509953cf5ba5f0032058c535",
"gitTreeSha": "572f7952ac451a30a9c2451b472a639b125ee584",
"files": [
{
"path": "SKILL.md",
@@ -58,9 +52,7 @@
{
"name": "orca-emulator",
"sourcePath": "skills/orca-emulator",
"releaseRevision": 8,
"packageDigest": "1dc42e5addc613abd85eba639d4ac36d9c7b3bc6f7186f0a2d54d10dae3f06d3",
"gitTreeSha": "110f6ab59bd73428d7de28bd4761d1fc332efa20",
"files": [
{
"path": "SKILL.md",
@@ -76,9 +68,7 @@
{
"name": "orca-emulator-android",
"sourcePath": "skills/orca-emulator-android",
"releaseRevision": 6,
"packageDigest": "c348091d953427fc9800a1d49d94054866348008766879b24ef742cb613dc3d9",
"gitTreeSha": "437ed5e35698ee6421386a5a08fe7f8c7cf1a2a7",
"files": [
{
"path": "SKILL.md",
@@ -94,9 +84,7 @@
{
"name": "orca-linear",
"sourcePath": "skills/orca-linear",
"releaseRevision": 9,
"packageDigest": "95f52429823e887317046f23b671d05408a947c296e5b2e44e9173dfc1d5aa4e",
"gitTreeSha": "8e747165847651926ca54804b012689ebcbd9432",
"files": [
{
"path": "SKILL.md",
@@ -112,9 +100,7 @@
{
"name": "orca-per-workspace-env",
"sourcePath": "skills/orca-per-workspace-env",
"releaseRevision": 6,
"packageDigest": "103f0671da111c9ad3def6c46da8d432e656878b840e6cc742219f4a3a4dbceb",
"gitTreeSha": "58dfb5dc3ad287a6a42625f9d15c7c0c3dfd02ec",
"files": [
{
"path": "SKILL.md",
@@ -130,9 +116,7 @@
{
"name": "orchestration",
"sourcePath": "skills/orchestration",
"releaseRevision": 29,
"packageDigest": "195f26431ecfb6df41b941b22958a2330b110bac7b7e97004e0b35fe586e41d9",
"gitTreeSha": "b0cd1d58b0c317cf7c3726fef7e6b1197c405246",
"files": [
{
"path": "SKILL.md",
-657
View File
@@ -1,657 +0,0 @@
{
"schemaVersion": 1,
"releases": [
{
"appVersion": "1.0.81",
"skills": {
"orca-cli": 1
}
},
{
"appVersion": "1.0.86",
"skills": {
"orca-cli": 2
}
},
{
"appVersion": "1.0.94-rc.0",
"skills": {
"orca-cli": 3
}
},
{
"appVersion": "1.3.8-rc.1",
"skills": {
"orca-cli": 4
}
},
{
"appVersion": "1.3.12-rc.4",
"skills": {
"orca-cli": 5
}
},
{
"appVersion": "1.3.24-rc.2",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.33-rc.3",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "0.0.1",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.35-rc.0",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.38-rc.3",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.38",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.39",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.46-rc.3",
"skills": {
"orca-cli": 7,
"orchestration": 2
}
},
{
"appVersion": "1.3.47-rc.0",
"skills": {
"orca-cli": 8,
"orchestration": 3
}
},
{
"appVersion": "1.3.49-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 8,
"orchestration": 3
}
},
{
"appVersion": "1.4.2-rc.10",
"skills": {
"computer-use": 1,
"orca-cli": 9,
"orchestration": 4
}
},
{
"appVersion": "1.4.2",
"skills": {
"computer-use": 1,
"orca-cli": 10,
"orchestration": 5
}
},
{
"appVersion": "1.4.3-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 11,
"orchestration": 6
}
},
{
"appVersion": "1.4.9-rc.1",
"skills": {
"computer-use": 1,
"orca-cli": 12,
"orchestration": 6
}
},
{
"appVersion": "1.4.10-rc.3",
"skills": {
"computer-use": 1,
"orca-cli": 13,
"orchestration": 6
}
},
{
"appVersion": "1.4.11-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 14,
"orchestration": 6
}
},
{
"appVersion": "1.4.18-rc.2",
"skills": {
"computer-use": 1,
"orca-cli": 15,
"orchestration": 6
}
},
{
"appVersion": "1.4.18-rc.3",
"skills": {
"computer-use": 1,
"orca-cli": 16,
"orchestration": 6
}
},
{
"appVersion": "1.4.22",
"skills": {
"computer-use": 1,
"orca-cli": 16,
"orchestration": 7
}
},
{
"appVersion": "1.4.36-rc.2",
"skills": {
"computer-use": 1,
"orca-cli": 17,
"orchestration": 8
}
},
{
"appVersion": "1.4.36-rc.6",
"skills": {
"computer-use": 1,
"orca-cli": 18,
"orchestration": 9
}
},
{
"appVersion": "1.4.42-rc.8",
"skills": {
"computer-use": 2,
"orca-cli": 19,
"orchestration": 10
}
},
{
"appVersion": "1.4.46-rc.0",
"skills": {
"computer-use": 2,
"orca-cli": 19,
"orchestration": 11
}
},
{
"appVersion": "1.4.51-rc.8",
"skills": {
"computer-use": 3,
"orca-cli": 19,
"orchestration": 11
}
},
{
"appVersion": "1.4.51-rc.9",
"skills": {
"computer-use": 3,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.65-rc.3",
"skills": {
"computer-use": 3,
"linear-tickets": 1,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.65",
"skills": {
"computer-use": 3,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.66-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 1,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.67-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.68",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 13
}
},
{
"appVersion": "1.4.74-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 21,
"orca-emulator": 1,
"orchestration": 14
}
},
{
"appVersion": "1.4.78-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 22,
"orca-emulator": 1,
"orchestration": 15
}
},
{
"appVersion": "1.4.78-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 23,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.81-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 24,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.81-rc.3",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 25,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.88",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 26,
"orca-emulator": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.90",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 26,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.92-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 27,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.96-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 28,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.105-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orchestration": 18
}
},
{
"appVersion": "1.4.111-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 1,
"orchestration": 18
}
},
{
"appVersion": "1.4.124-rc.6",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 2,
"orchestration": 18
}
},
{
"appVersion": "1.4.124-rc.9",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 2,
"orchestration": 19
}
},
{
"appVersion": "1.4.124",
"skills": {
"computer-use": 3,
"linear-tickets": 4,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 20
}
},
{
"appVersion": "1.4.137-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 4,
"orca-cli": 30,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 21
}
},
{
"appVersion": "1.4.138-rc.0",
"skills": {
"computer-use": 4,
"linear-tickets": 4,
"orca-cli": 31,
"orca-emulator": 3,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 22
}
},
{
"appVersion": "1.4.138-rc.3",
"skills": {
"computer-use": 4,
"linear-tickets": 4,
"orca-cli": 31,
"orca-emulator": 3,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 23
}
},
{
"appVersion": "1.4.138-rc.4",
"skills": {
"computer-use": 4,
"linear-tickets": 4,
"orca-cli": 31,
"orca-emulator": 3,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 24
}
},
{
"appVersion": "1.4.141",
"skills": {
"computer-use": 5,
"linear-tickets": 4,
"orca-cli": 32,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 24
}
},
{
"appVersion": "1.4.147-rc.2.ghes",
"skills": {
"computer-use": 5,
"linear-tickets": 4,
"orca-cli": 32,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 25
}
},
{
"appVersion": "1.4.148-rc.1",
"skills": {
"computer-use": 5,
"linear-tickets": 4,
"orca-cli": 33,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 25
}
},
{
"appVersion": "1.4.148",
"skills": {
"computer-use": 5,
"linear-tickets": 4,
"orca-cli": 34,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 25
}
},
{
"appVersion": "1.4.149-rc.0",
"skills": {
"computer-use": 5,
"linear-tickets": 4,
"orca-cli": 35,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 25
}
},
{
"appVersion": "1.4.149-rc.1.issue7936",
"skills": {
"computer-use": 5,
"linear-tickets": 5,
"orca-cli": 35,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 3,
"orca-per-workspace-env": 2,
"orchestration": 25
}
},
{
"appVersion": "1.4.149-rc.2",
"skills": {
"computer-use": 5,
"linear-tickets": 6,
"orca-cli": 35,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 4,
"orca-per-workspace-env": 2,
"orchestration": 25
}
},
{
"appVersion": "1.4.150-rc.0",
"skills": {
"computer-use": 5,
"linear-tickets": 7,
"orca-cli": 35,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 5,
"orca-per-workspace-env": 2,
"orchestration": 25
}
},
{
"appVersion": "1.4.151-rc.1",
"skills": {
"computer-use": 6,
"linear-tickets": 8,
"orca-cli": 35,
"orca-emulator": 5,
"orca-emulator-android": 3,
"orca-linear": 6,
"orca-per-workspace-env": 3,
"orchestration": 26
}
},
{
"appVersion": "1.4.151",
"skills": {
"computer-use": 7,
"linear-tickets": 9,
"orca-cli": 35,
"orca-emulator": 6,
"orca-emulator-android": 4,
"orca-linear": 7,
"orca-per-workspace-env": 4,
"orchestration": 27
}
},
{
"appVersion": "1.4.151-rc.2",
"skills": {
"computer-use": 8,
"linear-tickets": 10,
"orca-cli": 35,
"orca-emulator": 7,
"orca-emulator-android": 5,
"orca-linear": 8,
"orca-per-workspace-env": 5,
"orchestration": 28
}
},
{
"appVersion": "1.4.178-rc.2",
"skills": {
"computer-use": 8,
"linear-tickets": 10,
"orca-cli": 36,
"orca-emulator": 7,
"orca-emulator-android": 5,
"orca-linear": 8,
"orca-per-workspace-env": 5,
"orchestration": 28
}
},
{
"appVersion": "1.4.214",
"skills": {
"computer-use": 9,
"linear-tickets": 11,
"orca-cli": 37,
"orca-emulator": 8,
"orca-emulator-android": 6,
"orca-linear": 9,
"orca-per-workspace-env": 6,
"orchestration": 29
}
}
]
}
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readOrcaCliVersion } from './cli-version'
import { readOrcaCliBuildCommit, readOrcaCliVersion } from './cli-version'
const temporaryDirectories: string[] = []
@@ -16,9 +16,13 @@ describe('CLI version', () => {
const runtimeDir = join(root, 'cli')
temporaryDirectories.push(root)
await mkdir(runtimeDir)
await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.4.178-rc.2' }))
await writeFile(
join(root, 'package.json'),
JSON.stringify({ version: '1.4.178-rc.2', commit: 'b'.repeat(40) })
)
expect(readOrcaCliVersion(runtimeDir)).toBe('1.4.178-rc.2')
expect(readOrcaCliBuildCommit(runtimeDir)).toBe('b'.repeat(40))
})
it('rejects missing, malformed, and non-string versions', async () => {
+14 -6
View File
@@ -1,14 +1,22 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
// Node-mode CLI code cannot read the package metadata inside app.asar.
export function readOrcaCliVersion(runtimeDir = __dirname): string | null {
function readCompiledOutputField(runtimeDir: string, field: 'version' | 'commit'): string | null {
try {
const parsed = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8')) as {
version?: unknown
}
return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null
const parsed: unknown = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8'))
const value = parsed && typeof parsed === 'object' && field in parsed ? parsed[field] : null
return typeof value === 'string' && value.length > 0 ? value : null
} catch {
return null
}
}
// Node-mode CLI code cannot read the package metadata inside app.asar.
export function readOrcaCliVersion(runtimeDir = __dirname): string | null {
return readCompiledOutputField(runtimeDir, 'version')
}
/** The commit the CLI was built from, stamped into the same package boundary. */
export function readOrcaCliBuildCommit(runtimeDir = __dirname): string | null {
return readCompiledOutputField(runtimeDir, 'commit')
}
+25 -37
View File
@@ -6,22 +6,18 @@ import { writeStdoutLine } from '../stdout-line'
import { loadCanonicalGuides, type BundledSkillGuide } from './bundled-skill-guide-table'
import { SKILL_GUIDE_GET_HANDLER } from './skill-guide-get'
import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection'
import {
getTuiAgentDetectionProbeCommands,
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
resolveDetectedTuiAgentIds
} from '../../shared/tui-agent-detection-commands'
import {
getSpawnArgsForWindows,
UnsafeWindowsBatchArgumentsError,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { isUsableSkillsCliAgentKey, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys'
import { isUsableSkillsCliAgentKey } from '../../shared/skills-cli-agent-keys'
import {
buildAgentFeatureSkillInstallArgs,
buildAgentFeatureSkillUpdateArgs
} from '../../shared/agent-feature-install-commands'
detectSkillsCliAgentKeys,
detectSkillsCliUpdateAgentKeys
} from '../../shared/skills-cli-agent-detection'
import { buildAgentFeatureSkillInstallArgs } from '../../shared/agent-feature-install-commands'
import { readOrcaCliBuildCommit } from '../cli-version'
function resolveSelectedSkillNames(
flags: Map<string, string | boolean>,
@@ -116,19 +112,7 @@ function runNpxSkills(args: string[]): Promise<number> {
type SkillMutationVerb = 'install' | 'update'
/** Agents Orca can see on this host, as `skills --agent` keys. */
function detectSkillsCliAgentKeys(): string[] {
const runtime = process.platform
const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime)
const detected = resolveDetectedTuiAgentIds(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
detectCommandsInInstallDirs(probes),
runtime
)
return detected.length === 0 ? [] : toSkillsCliAgentKeys(detected)
}
function resolveInstallAgentKeys(flags: Map<string, string | boolean>): string[] {
function resolveAgentKeys(verb: SkillMutationVerb, flags: Map<string, string | boolean>): string[] {
const requested = flags.get('agent')
if (flags.has('agent') && typeof requested !== 'string') {
throw new RuntimeClientError('invalid_argument', 'Missing required --agent')
@@ -162,6 +146,9 @@ function resolveInstallAgentKeys(flags: Map<string, string | boolean>): string[]
}
return keys
}
if (verb === 'update') {
return detectSkillsCliUpdateAgentKeys()
}
const detected = detectSkillsCliAgentKeys()
if (detected.length > 0) {
return detected
@@ -177,19 +164,20 @@ function resolveInstallAgentKeys(flags: Map<string, string | boolean>): string[]
)
}
function buildNpxSkillsArgs(
verb: SkillMutationVerb,
skillNames: string[],
global: boolean,
agents: string[]
): string[] {
const skillArgs =
verb === 'install'
? buildAgentFeatureSkillInstallArgs(skillNames, { global, yes: true, agents })
: buildAgentFeatureSkillUpdateArgs(skillNames, { global, yes: true })
function buildNpxSkillsArgs(skillNames: string[], global: boolean, agents: string[]): string[] {
const sourceCommit = readOrcaCliBuildCommit()
if (!sourceCommit) {
throw new RuntimeClientError(
'invalid_environment',
'Could not read the commit this Orca CLI was built from, so there is no source to install skills from.'
)
}
// Why: a cold package cache makes bare `npx` prompt before it will fetch
// `skills`, which strands an unattended host just like the picker does.
return ['--yes', ...skillArgs]
return [
'--yes',
...buildAgentFeatureSkillInstallArgs(skillNames, { sourceCommit, global, yes: true, agents })
]
}
/** Render the exact argv a real run spawns, so --dry-run can never drift from it. */
@@ -235,9 +223,9 @@ function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
}
const global = flags.get('local') !== true
// Why: install scopes its targets; update only refreshes what is already placed.
const agents = verb === 'install' ? resolveInstallAgentKeys(flags) : []
const npxArgs = buildNpxSkillsArgs(verb, skillNames, global, agents)
// Why: update is the same pinned `skills add`; `skills update` follows GitHub HEAD,
// which runs ahead of the build this CLI judges freshness against.
const npxArgs = buildNpxSkillsArgs(skillNames, global, resolveAgentKeys(verb, flags))
const command = formatNpxCommand(npxArgs)
const dryRun = flags.get('dry-run') === true
+1 -1
View File
@@ -35,7 +35,7 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' skills list List version-matched skill guides bundled with this Orca CLI',
' skills get Print a version-matched skill guide as Markdown',
' skills install Install bundled Orca skills globally via the community skills CLI',
' skills update Update already-installed Orca skills via the community skills CLI',
' skills update Update Orca skills to the version this Orca build ships',
'',
'Hosts:',
' host list List targetable machines and how to name each one',
+93 -83
View File
@@ -5,16 +5,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter, join } from 'node:path'
import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution'
import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../shared/windows-batch-spawn'
import { SKILLS_CLI_VERSION } from '../shared/agent-feature-install-commands'
const COMMIT = '0123456789abcdef0123456789abcdef01234567'
const PINNED_ADD = [
'--yes',
`skills@${SKILLS_CLI_VERSION}`,
'add',
`https://github.com/stablyai/orca/tree/${COMMIT}`
]
const DETECTED_AGENTS = ['--agent', 'claude-code', '--agent', 'universal']
const {
detectCommandsMock,
guideModuleLoadMock,
readOrcaCliBuildCommitMock,
resolveCliCommandMock,
runtimeClientConstructorMock,
spawnMock
} = vi.hoisted(() => ({
detectCommandsMock: vi.fn(() => new Set<string>(['claude'])),
guideModuleLoadMock: vi.fn(),
readOrcaCliBuildCommitMock: vi.fn<() => string | null>(() => null),
resolveCliCommandMock: vi.fn(() => 'npx'),
runtimeClientConstructorMock: vi.fn(),
spawnMock: vi.fn()
@@ -32,6 +44,11 @@ vi.mock('../shared/node-cli-command-resolution', async (importOriginal) => ({
resolveCliCommand: resolveCliCommandMock
}))
// Why: the install pins to the build's commit; a test run has no compiled package boundary.
vi.mock('./cli-version', () => ({
readOrcaCliBuildCommit: readOrcaCliBuildCommitMock
}))
vi.mock('node:child_process', () => ({
spawn: spawnMock
}))
@@ -101,6 +118,8 @@ describe('orca skills CLI', () => {
detectCommandsMock.mockReset()
detectCommandsMock.mockReturnValue(new Set<string>(['claude']))
spawnMock.mockReset()
readOrcaCliBuildCommitMock.mockReset()
readOrcaCliBuildCommitMock.mockReturnValue(COMMIT)
process.exitCode = undefined
})
@@ -225,7 +244,7 @@ describe('orca skills CLI', () => {
'install Install bundled Orca skills'
)
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
'update Update already-installed Orca skills'
'update Update Orca skills to the version this Orca build ships'
)
expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills installed')
expect(String(logSpy.mock.calls[2]?.[0])).toContain('skills update')
@@ -339,7 +358,7 @@ describe('orca skills CLI', () => {
await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
`npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y\n\n` +
'Rerun without --dry-run to install now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
@@ -353,8 +372,7 @@ describe('orca skills CLI', () => {
expect(stdoutText(stdoutSpy)).toBe(
`${JSON.stringify(
{
command:
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y',
command: `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y`,
skills: ['alpha'],
global: true,
executed: false
@@ -371,7 +389,7 @@ describe('orca skills CLI', () => {
await main(['skills', 'install', '--skill', 'alpha', '--local', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y\n\n' +
`npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --agent claude-code --agent universal -y\n\n` +
'Rerun without --dry-run to install now.\n'
)
@@ -384,8 +402,7 @@ describe('orca skills CLI', () => {
expect(stdoutText(stdoutSpy)).toBe(
`${JSON.stringify(
{
command:
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y',
command: `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --agent claude-code --agent universal -y`,
skills: ['alpha'],
global: false,
executed: false
@@ -408,19 +425,7 @@ describe('orca skills CLI', () => {
expect(spawnMock).toHaveBeenCalledWith(
'npx',
[
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
'--skill',
'alpha',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
],
[...PINNED_ADD, '--skill', 'alpha', ...DETECTED_AGENTS, '-y'],
expect.objectContaining({ stdio: 'inherit' })
)
})
@@ -445,16 +450,13 @@ describe('orca skills CLI', () => {
'/c',
'C:\\Program Files\\nodejs\\npx.cmd',
'--yes',
'skills',
`skills@${SKILLS_CLI_VERSION}`,
'add',
'https://github.com/stablyai/orca',
`https://github.com/stablyai/orca/tree/${COMMIT}`,
'--skill',
'alpha',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
...DETECTED_AGENTS,
'-y'
],
expect.objectContaining({ stdio: 'inherit' })
@@ -491,20 +493,7 @@ describe('orca skills CLI', () => {
expect(spawnMock).toHaveBeenCalledWith(
'npx',
[
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
'--skill',
'alpha',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
],
[...PINNED_ADD, '--skill', 'alpha', '--global', ...DETECTED_AGENTS, '-y'],
expect.objectContaining({ stdio: 'inherit' })
)
})
@@ -522,10 +511,7 @@ describe('orca skills CLI', () => {
expect(spawnMock).toHaveBeenCalledWith(
'npx',
[
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
...PINNED_ADD,
'--skill',
'alpha',
'--skill',
@@ -533,10 +519,7 @@ describe('orca skills CLI', () => {
'--skill',
'zeta',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
...DETECTED_AGENTS,
'-y'
],
expect.objectContaining({ stdio: 'inherit' })
@@ -587,7 +570,8 @@ describe('orca skills CLI', () => {
await main(['skills', 'update', '--skill', 'legacy-alpha', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills update alpha --global -y\n\nRerun without --dry-run to update now.\n'
`npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y\n\n` +
'Rerun without --dry-run to update now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
})
@@ -603,7 +587,7 @@ describe('orca skills CLI', () => {
expect(stdoutText(stdoutSpy)).toBe(
`${JSON.stringify(
{
command: 'npx --yes skills update alpha --project -y',
command: `npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --agent claude-code --agent universal -y`,
skills: ['alpha'],
global: false,
executed: false
@@ -626,7 +610,7 @@ describe('orca skills CLI', () => {
expect(spawnMock).toHaveBeenCalledWith(
'npx',
['--yes', 'skills', 'update', 'alpha', '--project', '-y'],
[...PINNED_ADD, '--skill', 'alpha', ...DETECTED_AGENTS, '-y'],
expect.objectContaining({ stdio: 'inherit' })
)
})
@@ -805,21 +789,51 @@ describe('orca skills CLI', () => {
)
})
it('never sends --agent for an update, and never refuses on a bare host', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
// Why: update refreshes what is already placed, so the no-agent refusal that
// guards install must not reach it.
it('updates with the same pinned add and agent targets as install', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(['skills', 'install', '--skill', 'alpha', '--dry-run', '--json'], '/tmp/repo')
await main(['skills', 'update', '--skill', 'alpha', '--dry-run', '--json'], '/tmp/repo')
const [install, update] = stdoutSpy.mock.calls.map((call) => JSON.parse(String(call[0])))
// Why: `skills update` follows GitHub HEAD, which runs ahead of this release.
expect(update.command).toBe(install.command)
expect(update.command).toContain(`add https://github.com/stablyai/orca/tree/${COMMIT}`)
})
it('honours an explicit --agent list for an update', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(
['skills', 'update', '--skill', 'alpha', '--agent', 'universal', '--dry-run'],
'/tmp/repo'
)
expect(stdoutText(stdoutSpy)).toContain(
`npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent universal -y`
)
})
it('never refuses an update on a bare host; it targets the shared folder', async () => {
detectCommandsMock.mockReturnValue(new Set<string>())
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'update', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
await main(['skills', 'update', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
// Why: update refreshes what is already placed; it chooses no new targets.
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--agent')
expect(stdoutText(stdoutSpy)).toContain(
`npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent universal -y`
)
})
it('refuses to run when the CLI cannot read its own release version', async () => {
readOrcaCliBuildCommitMock.mockReturnValue(null)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['skills', 'update', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
expect(spawnMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Could not read the commit')
})
it.each([
@@ -893,22 +907,7 @@ describe('orca skills CLI', () => {
// repeatable-flag set silently installs one skill instead of two.
expect(spawnMock).toHaveBeenCalledWith(
'npx',
[
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
'--skill',
'alpha',
'--skill',
'zeta',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
],
[...PINNED_ADD, '--skill', 'alpha', '--skill', 'zeta', '--global', ...DETECTED_AGENTS, '-y'],
expect.objectContaining({ stdio: 'inherit' })
)
})
@@ -922,7 +921,7 @@ describe('orca skills CLI', () => {
)
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
`npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y\n\n` +
'Rerun without --dry-run to install now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
@@ -940,11 +939,11 @@ describe('orca skills CLI', () => {
// Why: stdout belongs to the child, so this record has to go to stderr.
expect(stderrSpy).toHaveBeenCalledWith(
'Running: npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n'
`Running: npx --yes skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${COMMIT} --skill alpha --global --agent claude-code --agent universal -y\n`
)
})
it('runs npx skills update for --all and forwards its exit code', async () => {
it('runs the pinned add for update --all and forwards its exit code', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
@@ -956,7 +955,18 @@ describe('orca skills CLI', () => {
expect(spawnMock).toHaveBeenCalledWith(
'npx',
['--yes', 'skills', 'update', 'alpha', 'gamma', 'zeta', '--global', '-y'],
[
...PINNED_ADD,
'--skill',
'alpha',
'--skill',
'gamma',
'--skill',
'zeta',
'--global',
...DETECTED_AGENTS,
'-y'
],
expect.objectContaining({ stdio: 'inherit' })
)
expect(process.exitCode).toBe(2)
+13 -10
View File
@@ -73,9 +73,10 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'dry-run'],
notes: [
'Reads the bundled skill registry locally without contacting the Orca runtime.',
'Resolves to the same `npx skills add <repo> --skill <name> ...` command used by ' +
'Orca Settings, plus the non-interactive flags an unattended host needs ' +
'(`npx --yes` and `-y`), then runs it and forwards its output and exit code.',
'Resolves to the same `npx skills@<pinned> add <repo>/tree/<commit> --skill <name> ...` ' +
'command used by Orca Settings, pinned to the commit this Orca build was made from, ' +
'plus the non-interactive flags an unattended host needs (`npx --yes` and `-y`), ' +
'then runs it and forwards its output and exit code.',
'Installs globally (all projects, adds --global) by default. Use --local to install ' +
'into the current project instead.',
'Targets the coding agents Orca detects on this host, plus the shared ' +
@@ -100,17 +101,19 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['skills', 'update'],
summary: 'Update already-installed Orca skills via the community skills CLI',
usage: 'orca skills update [--skill <name>]... [--all] [--local] [--dry-run] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'local', 'dry-run'],
summary: 'Update Orca skills to the version this Orca build ships',
usage:
'orca skills update [--skill <name>]... [--all] [--agent <name>[,<name>]] ' +
'[--local] [--dry-run] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'dry-run'],
notes: [
'Reads the bundled skill registry locally without contacting the Orca runtime.',
'Resolves to the same `npx skills update <names...>` command used by Orca Settings, ' +
'plus the non-interactive flags an unattended host needs (`npx --yes` and `-y`), ' +
'then runs it and forwards its output and exit code.',
'Runs the same pinned `npx skills add` command as `orca skills install`, which ' +
'replaces each selected skill with the copy this Orca build ships.',
'Updates the global install (all projects, adds --global) by default. Use --local to ' +
'update the current project instead.',
'Only refreshes skills that are already installed; use `orca skills install` first.',
'Targets the same agents as `orca skills install`, or only the shared ' +
'.agents/skills directory when none is detected; use --agent to choose them yourself.',
'Use --dry-run to print the resolved command without running it.',
'With --json, the skill listing and --dry-run emit JSON; a real update streams ' +
"npx's own non-JSON output live and rejects --json.",
@@ -22,7 +22,7 @@ import { getStatus } from './status'
// Git normalizes to `//./nul` and rejects — `fatal: unable to access '//./nul': Invalid
// argument`. Every git call in this file then threw. POSIX resolves it to /dev/null,
// which Git accepts, so CI never saw it. An empty file works on every platform and
// matches how skill-git-tree-identity and skill-windows-workspace already isolate.
// matches how skill-windows-workspace already isolates.
//
// Why process.env and not just this helper's env: resolveWorktreeSharedDirectories
// runs its own `git check-ignore` through the production runner, which inherits
+1 -4
View File
@@ -251,10 +251,7 @@ describe('registerSkillsHandlers', () => {
await handler(null)
expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({
currentAppVersion: '9.9.9-test',
repos
})
expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos })
expect(getWslHomeMock).not.toHaveBeenCalled()
})
})
+4 -13
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron'
import { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import {
@@ -14,7 +14,6 @@ import type {
import { inventorySkillFreshness } from '../skills/skill-freshness-inventory'
import { SkillUpdateRunner } from '../skills/skill-update-run'
import { skillUpdateFailedNames } from '../skills/skill-update-outcome'
import { readGloballyUpdatableSkillLocks } from '../skills/skill-update-registration'
import {
clearSkillDiscoveryCaches,
discoverSkillsOnTarget,
@@ -35,25 +34,17 @@ export function registerSkillsHandlers(store: Store, runtime?: OrcaRuntimeServic
const scanInventory = (): Promise<SkillFreshnessInventory> =>
// Why: the update command targets this machine's global homes. WSL and SSH
// inventories stay out until their installer rail has an equivalent proof.
inventorySkillFreshness({
currentAppVersion: app.getVersion(),
repos: store.getRepos()
})
inventorySkillFreshness({ repos: store.getRepos() })
const runner = new SkillUpdateRunner({
sourceCommit: ORCA_BUILD_COMMIT,
// Why: per-skill outcomes come from re-hashing what is actually on disk, not
// from scraping stdout.
rescanOutdatedNames: async (names) => {
// Why: the run just rewrote skill packages on this host. Clients that never
// send `refresh` (older builds) would otherwise read a pre-run scan.
clearSkillDiscoveryCaches()
// The lock read is fresh on purpose: the run just rewrote it, and the
// verdict accepts unrecognized content only when disk matches that record.
const [inventory, globalSkillLocks] = await Promise.all([
scanInventory(),
readGloballyUpdatableSkillLocks()
])
return skillUpdateFailedNames(names, inventory.installations, globalSkillLocks)
return skillUpdateFailedNames(names, (await scanInventory()).installations)
},
onState: (run: SkillUpdateRun) => {
for (const window of BrowserWindow.getAllWindows()) {
+24 -63
View File
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
import { loadSkillBundleManifest } from './skill-bundle-artifacts'
const temporaryDirectories: string[] = []
@@ -10,70 +10,31 @@ afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('skill bundle artifacts', () => {
it('loads the committed schema-2 artifacts and rejects a stamped schema-1 manifest', async () => {
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
temporaryDirectories.push(resourceRoot)
const target = join(resourceRoot, 'skills')
const source = resolve('resources', 'skills')
await mkdir(target, { recursive: true })
const [manifest, registry, releaseMapping] = await Promise.all(
['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) =>
readFile(join(source, name), 'utf8')
)
)
await Promise.all([
writeFile(join(target, 'current-manifest.json'), manifest),
writeFile(join(target, 'snapshot-registry.json'), registry),
writeFile(join(target, 'release-mapping.json'), releaseMapping)
])
async function resourceRootWith(manifest: string): Promise<string> {
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
temporaryDirectories.push(resourceRoot)
await mkdir(join(resourceRoot, 'skills'), { recursive: true })
await writeFile(join(resourceRoot, 'skills', 'current-manifest.json'), manifest)
return resourceRoot
}
const artifacts = await loadSkillBundleArtifacts(resourceRoot)
expect(artifacts.manifest.schemaVersion).toBe(2)
expect(artifacts.manifest.skills.length).toBeGreaterThan(0)
describe('skill bundle manifest', () => {
it('loads the committed schema-3 manifest and rejects a schema-2 one', async () => {
const manifest = await readFile(resolve('resources', 'skills', 'current-manifest.json'), 'utf8')
const legacyRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
temporaryDirectories.push(legacyRoot)
const legacyTarget = join(legacyRoot, 'skills')
await mkdir(legacyTarget, { recursive: true })
const legacyManifest = JSON.parse(manifest)
legacyManifest.schemaVersion = 1
legacyManifest.appVersion = '1.0.0'
for (const skill of legacyManifest.skills) {
skill.appVersion = '1.0.0'
const loaded = await loadSkillBundleManifest(await resourceRootWith(manifest))
expect(loaded.schemaVersion).toBe(3)
expect(loaded.skills.length).toBeGreaterThan(0)
// Why: schema 2 carried registry revisions; a stale artifact must fail loudly.
const legacy = JSON.parse(manifest)
legacy.schemaVersion = 2
for (const skill of legacy.skills) {
skill.releaseRevision = 1
skill.gitTreeSha = '0'.repeat(40)
}
await Promise.all([
writeFile(join(legacyTarget, 'current-manifest.json'), JSON.stringify(legacyManifest)),
writeFile(join(legacyTarget, 'snapshot-registry.json'), registry),
writeFile(join(legacyTarget, 'release-mapping.json'), releaseMapping)
])
await expect(loadSkillBundleArtifacts(legacyRoot)).rejects.toThrow(
'Invalid skill bundle manifest'
)
})
it('rejects malformed nested release entries before building provenance', async () => {
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
temporaryDirectories.push(resourceRoot)
const target = join(resourceRoot, 'skills')
const source = resolve('resources', 'skills')
await mkdir(target, { recursive: true })
const [manifest, registry, releaseMapping] = await Promise.all(
['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) =>
readFile(join(source, name), 'utf8')
)
)
const malformedMapping = JSON.parse(releaseMapping)
malformedMapping.releases[0] = { appVersion: 'invalid' }
await Promise.all([
writeFile(join(target, 'current-manifest.json'), manifest),
writeFile(join(target, 'snapshot-registry.json'), registry),
writeFile(join(target, 'release-mapping.json'), JSON.stringify(malformedMapping))
])
await expect(loadSkillBundleArtifacts(resourceRoot)).rejects.toThrow(
'Invalid skill release mapping'
)
await expect(
loadSkillBundleManifest(await resourceRootWith(JSON.stringify(legacy)))
).rejects.toThrow('Invalid skill bundle manifest')
})
})
+37 -128
View File
@@ -1,159 +1,68 @@
import { app } from 'electron'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { z, type ZodType } from 'zod'
import type {
SkillBundleManifest,
SkillKnownSnapshot,
SkillReleaseMapping,
SkillSnapshotRegistry
} from '../../shared/skill-freshness'
export type SkillBundleArtifacts = {
manifest: SkillBundleManifest
registry: SkillSnapshotRegistry
releaseMapping: SkillReleaseMapping
knownSnapshots: Record<string, SkillKnownSnapshot[]>
releasedAppVersions: Record<string, Record<number, string>>
}
import { z } from 'zod'
import type { SkillBundleManifest } from '../../shared/skill-freshness'
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/)
const snapshotFields = {
releaseRevision: z.number().int().positive(),
packageDigest: sha256Schema,
gitTreeSha: z.string().regex(/^[a-f0-9]{40}$/),
files: z
.array(
z
.object({
path: z.string().min(1),
size: z.number().int().nonnegative(),
executable: z.boolean(),
classification: z.enum(['text', 'binary']),
exactSha256: sha256Schema,
textNormalizedSha256: sha256Schema.nullable(),
identitySha256: sha256Schema
})
.strict()
)
.min(1)
}
const knownSnapshotSchema = z.object(snapshotFields).strict()
const manifestSchema = z
.object({
schemaVersion: z.literal(2),
schemaVersion: z.literal(3),
skills: z.array(
z
.object({
name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
sourcePath: z.string().min(1),
...snapshotFields
})
.strict()
)
})
.strict()
const registrySchema = z
.object({
schemaVersion: z.literal(1),
skills: z.record(z.string().min(1), z.array(knownSnapshotSchema).min(1))
})
.strict()
const releaseMappingSchema = z
.object({
schemaVersion: z.literal(1),
releases: z.array(
z
.object({
appVersion: z.string().min(1),
skills: z.record(z.string().min(1), z.number().int().positive())
packageDigest: sha256Schema,
files: z
.array(
z
.object({
path: z.string().min(1),
size: z.number().int().nonnegative(),
executable: z.boolean(),
classification: z.enum(['text', 'binary']),
exactSha256: sha256Schema,
textNormalizedSha256: sha256Schema.nullable(),
identitySha256: sha256Schema
})
.strict()
)
.min(1)
})
.strict()
)
})
.strict()
function parseArtifact<T>(schema: ZodType<T>, value: unknown, label: string): T {
const result = schema.safeParse(value)
if (!result.success) {
throw new Error(`Invalid ${label}: ${result.error.issues[0]?.message ?? 'schema mismatch'}`)
}
return result.data
}
const manifestsByResourceRoot = new Map<string, Promise<SkillBundleManifest>>()
const artifactsByResourceRoot = new Map<string, Promise<SkillBundleArtifacts>>()
// Why: the artifacts ship with the binary and never change within a run, while
// focus-triggered rescans would otherwise re-read and re-parse them every time.
export function loadSkillBundleArtifacts(
// Why: the manifest ships with the binary and never changes within a run, while
// focus-triggered rescans would otherwise re-read and re-parse it every time.
export function loadSkillBundleManifest(
resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources')
): Promise<SkillBundleArtifacts> {
const cached = artifactsByResourceRoot.get(resourceRoot)
): Promise<SkillBundleManifest> {
const cached = manifestsByResourceRoot.get(resourceRoot)
if (cached) {
return cached
}
const loading = readSkillBundleArtifacts(resourceRoot)
artifactsByResourceRoot.set(resourceRoot, loading)
const loading = readSkillBundleManifest(resourceRoot)
manifestsByResourceRoot.set(resourceRoot, loading)
loading.catch(() => {
artifactsByResourceRoot.delete(resourceRoot)
manifestsByResourceRoot.delete(resourceRoot)
})
return loading
}
async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBundleArtifacts> {
const bundleRoot = join(resourceRoot, 'skills')
const [manifestValue, registryValue, releaseMappingValue] = await Promise.all([
readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse),
readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse),
readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse)
])
const manifest: SkillBundleManifest = parseArtifact(
manifestSchema,
manifestValue,
'skill bundle manifest'
async function readSkillBundleManifest(resourceRoot: string): Promise<SkillBundleManifest> {
const value: unknown = JSON.parse(
await readFile(join(resourceRoot, 'skills', 'current-manifest.json'), 'utf8')
)
const registry: SkillSnapshotRegistry = parseArtifact(
registrySchema,
registryValue,
'skill snapshot registry'
)
const releaseMapping: SkillReleaseMapping = parseArtifact(
releaseMappingSchema,
releaseMappingValue,
'skill release mapping'
)
for (const current of manifest.skills) {
if (
!registry.skills[current.name]?.some(
(snapshot) =>
snapshot.releaseRevision === current.releaseRevision &&
snapshot.packageDigest === current.packageDigest
)
) {
throw new Error(`Inconsistent current skill snapshot: ${current.name}`)
}
}
// Why: historical provenance only — the current revision's label is the
// running build's version, supplied at the inventory boundary, not stored here.
const releasedAppVersions: Record<string, Record<number, string>> = {}
for (const release of releaseMapping.releases) {
for (const [name, revision] of Object.entries(release.skills)) {
if (!registry.skills[name]?.some((snapshot) => snapshot.releaseRevision === revision)) {
throw new Error(`Unknown released skill revision: ${name}@${revision}`)
}
releasedAppVersions[name] ??= {}
releasedAppVersions[name][revision] ??= release.appVersion
}
}
return {
manifest,
registry,
releaseMapping,
// Why: newer-known classification needs every identity packaged with this
// build, while release mapping remains the provenance record for shipped revisions.
knownSnapshots: registry.skills,
releasedAppVersions
const result = manifestSchema.safeParse(value)
if (!result.success) {
throw new Error(
`Invalid skill bundle manifest: ${result.error.issues[0]?.message ?? 'schema mismatch'}`
)
}
return result.data
}
@@ -1,16 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
buildTargetedSkillUpdateCommand,
canonicalizeSkillUpdateNames,
type SkillFreshnessInstallation
} from '../../shared/skill-freshness'
import { eligibleSkillUpdateNames } from './skill-freshness-eligibility'
const globallyUpdatableNames = new Set(['computer-use', 'orca-cli', 'orchestration'])
function eligible(installations: SkillFreshnessInstallation[]): string[] {
return eligibleSkillUpdateNames(installations, globallyUpdatableNames)
}
function placement(
name: string,
overrides: Partial<SkillFreshnessInstallation> = {}
@@ -27,21 +21,17 @@ function placement(
physicalIdentity: `physical-${name}`,
topology: 'canonical-copy',
status: 'outdated',
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
lockSource: 'stablyai/orca',
errorCategory: null,
...overrides
}
}
describe('skill freshness name-scoped update eligibility', () => {
it('offers a name when at least one supported placement is outdated and all are official', () => {
it('offers a name when at least one supported placement is outdated', () => {
expect(
eligible([
eligibleSkillUpdateNames([
placement('orca-cli'),
placement('orca-cli', {
id: 'orca-cli-claude',
@@ -53,22 +43,27 @@ describe('skill freshness name-scoped update eligibility', () => {
).toEqual(['orca-cli'])
})
it.each([null, 'acme/agent-skills'])(
'offers an outdated copy whatever the lock says installed it (%s)',
(lockSource) => {
// Why: ownership only decides preselection; #21495 came from gating on the lock.
expect(eligibleSkillUpdateNames([placement('orca-cli', { lockSource })])).toEqual([
'orca-cli'
])
}
)
it.each([
['newer-known', 'independent-copy'],
['unrecognized', 'independent-copy'],
['inaccessible', 'broken-link'],
['current', 'external-link'],
['current', 'read-only'],
['outdated', 'independent-copy'],
['current', 'repo-scope'],
['current', 'plugin-cache']
['outdated', 'repo-scope'],
['outdated', 'plugin-cache'],
['inaccessible', 'plugin-cache']
] as const)(
'still updates the canonical copy despite a %s placement in %s topology',
(status, topology) => {
// Why: `--global` provably never writes these placements, so withholding the
// update over one refuses work the command could do to a copy that is never at
// stake. The canonical copy converges and the outlier is reported separately.
expect(
eligible([
eligibleSkillUpdateNames([
placement('orca-cli'),
placement('orca-cli', { id: `outlier-${status}-${topology}`, status, topology })
])
@@ -76,49 +71,41 @@ describe('skill freshness name-scoped update eligibility', () => {
}
)
it.each(['unrecognized', 'inaccessible', 'newer-known'] as const)(
'withholds the update when the convergent copy itself is %s',
(status) => {
// Why: this is the placement the command writes to, so overwriting it is the
// real data-loss case the rail exists to avoid.
it.each(['external-link', 'broken-link', 'read-only'] as const)(
'skips the name when a home placement is a %s the install would write through',
(topology) => {
expect(
eligible([
placement('orca-cli', { id: 'blocked-canonical', status }),
eligibleSkillUpdateNames([
placement('orca-cli'),
placement('orca-cli', {
id: 'orca-cli-claude',
id: `orca-cli-${topology}`,
rootId: 'home-claude',
topology: 'provider-alias',
status: 'outdated'
unresolvedPath: '/home/.claude/skills/orca-cli',
topology,
status: topology === 'broken-link' ? 'inaccessible' : 'outdated'
})
])
).toEqual([])
}
)
it('still updates the canonical copy when a clean standalone duplicate exists', () => {
// Why: a duplicate no longer omits the whole name — the canonical copy converges
// and the duplicate row is flagged as maybe-not-reached rather than blocking.
it('withholds the update when the convergent copy itself is inaccessible', () => {
expect(
eligible([
placement('orca-cli'),
eligibleSkillUpdateNames([
placement('orca-cli', { id: 'blocked-canonical', status: 'inaccessible' }),
placement('orca-cli', {
id: 'orca-cli-gemini',
rootId: 'home-gemini',
unresolvedPath: '/home/.gemini/skills/orca-cli',
resolvedPath: '/home/.gemini/skills/orca-cli',
topology: 'independent-copy',
status: 'current'
id: 'orca-cli-claude',
rootId: 'home-claude',
topology: 'provider-alias',
status: 'outdated'
})
])
).toEqual(['orca-cli'])
).toEqual([])
})
it('does not promise an update when only an unreachable duplicate is outdated', () => {
// Why: `--global` converges the canonical copy and its aliases only. Offering the
// name here advertises an update the command reports as already up to date, so the
// badge could never clear; the dialog explains the duplicate as skipped instead.
it('does not promise an update when only a standalone duplicate is outdated', () => {
expect(
eligible([
eligibleSkillUpdateNames([
placement('orchestration', { status: 'current' }),
placement('orchestration', {
id: 'orchestration-factory',
@@ -134,46 +121,34 @@ describe('skill freshness name-scoped update eligibility', () => {
})
it('does not offer a skill that exists only as a standalone copy', () => {
// Why: with no canonical or alias to anchor `--global`, the command has no
// reliable target, so a duplicate-only skill stays unoffered.
expect(
eligible([
eligibleSkillUpdateNames([
placement('orca-cli', {
rootId: 'home-gemini',
unresolvedPath: '/home/.gemini/skills/orca-cli',
resolvedPath: '/home/.gemini/skills/orca-cli',
topology: 'independent-copy',
status: 'outdated'
topology: 'independent-copy'
})
])
).toEqual([])
})
it('scopes each name independently and leaves an all-current name alone', () => {
// Why: a project copy is never written by `--global`, so it does not speak for
// the global one — while a name whose convergent copy is current stays unoffered.
expect(
eligible([
eligibleSkillUpdateNames([
placement('computer-use', { status: 'current' }),
placement('orchestration'),
placement('orchestration', {
id: 'orchestration-project',
status: 'unrecognized',
topology: 'repo-scope'
})
placement('orchestration', { id: 'orchestration-project', topology: 'repo-scope' })
])
).toEqual(['orchestration'])
})
it('does not offer an official canonical copy missing from the updater lock (#10791)', () => {
expect(eligibleSkillUpdateNames([placement('orca-cli')], new Set())).toEqual([])
})
it('builds only an explicit, deterministic global command', () => {
expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe(
'npx skills update orca-cli orchestration --global'
)
expect(buildTargetedSkillUpdateCommand([])).toBeNull()
expect(buildTargetedSkillUpdateCommand(['orca-cli;echo unsafe'])).toBeNull()
it('canonicalizes only names that are safe to put on a command line', () => {
expect(canonicalizeSkillUpdateNames(['orchestration', 'orca-cli', 'orca-cli'])).toEqual([
'orca-cli',
'orchestration'
])
expect(canonicalizeSkillUpdateNames([])).toBeNull()
expect(canonicalizeSkillUpdateNames(['orca-cli;echo unsafe'])).toBeNull()
})
})
+23 -30
View File
@@ -1,24 +1,27 @@
import {
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES,
type SkillFreshnessInstallation
type SkillFreshnessInstallation,
type SkillInstallationTopology
} from '../../shared/skill-freshness'
// Why: `skills add` rewrites the skill in every agent folder it targets with an rm-then-copy.
// Through a link out of tree that deletes someone else's files (#22897); into a read-only or
// dangling placement it fails half-way. Any such home placement keeps the name skipped.
const UNWRITABLE_HOME_TOPOLOGIES: ReadonlySet<SkillInstallationTopology> = new Set([
'external-link',
'broken-link',
'read-only'
])
/**
* Names the global update command can actually converge.
* Names the pinned install can safely bring to this build's version.
*
* Eligibility is decided purely over the placements that command touches — the
* canonical copy and its symlink aliases. Copies it provably leaves alone (standalone
* duplicates, project skills, plugin caches, links out of tree) neither authorize an
* update nor withhold one: the badge would otherwise promise work the command cannot
* do, or refuse work it could, over a copy that is never at stake either way. A
* blocked *convergent* copy still withholds it, because that is the placement the
* command would write to and overwriting it is the real data-loss case.
* `globallyUpdatableNames` comes from the updater's lock; an unregistered copied
* bundle is installed but `skills update` cannot identify its source.
* Ownership is deliberately not consulted: whether to preselect a copy someone else
* installed is presentation policy. Copies the command never writes (project skills,
* plugin caches) neither authorize an update nor withhold one.
*/
export function eligibleSkillUpdateNames(
installations: readonly SkillFreshnessInstallation[],
globallyUpdatableNames: ReadonlySet<string>
installations: readonly SkillFreshnessInstallation[]
): string[] {
const byName = new Map<string, SkillFreshnessInstallation[]>()
for (const installation of installations) {
@@ -28,26 +31,16 @@ export function eligibleSkillUpdateNames(
}
const eligible: string[] = []
for (const [, entries] of byName) {
if (!globallyUpdatableNames.has(entries[0].name)) {
continue
}
for (const [name, entries] of byName) {
const convergent = entries.filter((entry) =>
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology)
)
// Why: without a convergent placement the command has no anchor, so it would
// no-op or error against a canonical install that isn't there.
if (convergent.length === 0) {
continue
}
const hasOutdated = convergent.some((entry) => entry.status === 'outdated')
const everyConvergentCopyIsSafeToWrite = convergent.every(
(entry) =>
(entry.status === 'current' || entry.status === 'outdated') &&
Boolean(entry.resolvedPath && entry.physicalIdentity)
)
if (hasOutdated && everyConvergentCopyIsSafeToWrite) {
eligible.push(entries[0].name)
if (
convergent.some((entry) => entry.status === 'outdated') &&
convergent.every((entry) => entry.status !== 'inaccessible') &&
entries.every((entry) => !UNWRITABLE_HOME_TOPOLOGIES.has(entry.topology))
) {
eligible.push(name)
}
}
return eligible.sort((left, right) => left.localeCompare(right, 'en'))
File diff suppressed because it is too large Load Diff
+23 -53
View File
@@ -1,13 +1,12 @@
import { lstat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Repo } from '../../shared/repo-types'
import {
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES,
type SkillFreshnessInstallation,
type SkillFreshnessInventory
import type {
SkillFreshnessInstallation,
SkillFreshnessInventory
} from '../../shared/skill-freshness'
import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources'
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
import { loadSkillBundleManifest } from './skill-bundle-artifacts'
import { eligibleSkillUpdateNames } from './skill-freshness-eligibility'
import { runSkillCandidateTasks } from './skill-candidate-concurrency'
import {
@@ -18,28 +17,10 @@ import {
type CandidateLstat
} from './skill-freshness-placement-observation'
import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan'
import { convergableSkillNames } from './skill-update-convergence'
import { matchesUpdaterLock, readGloballyUpdatableSkillLocks } from './skill-update-registration'
import { readSkillLockSources } from './skill-lock-sources'
export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128
// Why: the updater installs source-repo HEAD, which legitimately runs ahead of the
// bundled registry — content the bundle has never seen is the steady state right
// after an update. Bytes whose git tree sha equals the lock's recorded hash are the
// CLI's own install, not a user edit, so calling them "unrecognized" (modified) is
// false. Judged only over the placements the update command writes; a same-name
// copy elsewhere earns no trust from someone else's lock entry.
function trustLockInstalledRevision(
installation: SkillFreshnessInstallation,
globalSkillLocks: ReadonlyMap<string, string>
): SkillFreshnessInstallation {
return installation.status === 'unrecognized' &&
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) &&
matchesUpdaterLock(installation, globalSkillLocks.get(installation.name))
? { ...installation, status: 'newer-known' }
: installation
}
export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): {
scanned: SkillScanRoot[]
omitted: SkillScanRoot[]
@@ -51,9 +32,6 @@ export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): {
}
export async function inventorySkillFreshness(args: {
// Why: the bundled artifacts are content-only; the running build supplies
// its own version here so current placements can be labeled honestly.
currentAppVersion: string
homeDir?: string
cwd?: string
repos?: Repo[]
@@ -61,11 +39,12 @@ export async function inventorySkillFreshness(args: {
candidateLstat?: CandidateLstat
stateHome?: string | null
}): Promise<SkillFreshnessInventory> {
const [artifacts, globalSkillLocks] = await Promise.all([
loadSkillBundleArtifacts(args.resourceRoot),
readGloballyUpdatableSkillLocks({ homeDir: args.homeDir, stateHome: args.stateHome })
const [manifest, lockSources] = await Promise.all([
loadSkillBundleManifest(args.resourceRoot),
readSkillLockSources({ homeDir: args.homeDir, stateHome: args.stateHome })
])
const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill]))
const currentByName = new Map(manifest.skills.map((skill) => [skill.name, skill]))
const lockSourceOf = (name: string): string | null => lockSources.get(name) ?? null
const discoveryArgs = {
homeDir: args.homeDir,
cwd: args.cwd,
@@ -84,14 +63,13 @@ export async function inventorySkillFreshness(args: {
throw new Error('Missing canonical agent skills root')
}
const homeTasks = artifacts.manifest.skills.flatMap((current) =>
const homeTasks = manifest.skills.flatMap((current) =>
homeRoots.map(
(root) => () =>
classifyHomeSkillCandidate({
root,
current,
currentAppVersion: args.currentAppVersion,
artifacts,
lockSource: lockSourceOf(current.name),
canonicalRootPath,
candidateLstat: args.candidateLstat ?? ((path) => lstat(path))
})
@@ -104,14 +82,13 @@ export async function inventorySkillFreshness(args: {
)
const candidateLstat = args.candidateLstat ?? ((path) => lstat(path))
const repoTasks = artifacts.manifest.skills.flatMap((current) =>
const repoTasks = manifest.skills.flatMap((current) =>
repoRoots.map(
(root) => () =>
classifyUnsupportedSkillCandidate({
root,
current,
currentAppVersion: args.currentAppVersion,
artifacts,
lockSource: lockSourceOf(current.name),
unresolvedPath: join(root.path, current.name),
candidateLstat
})
@@ -122,12 +99,11 @@ export async function inventorySkillFreshness(args: {
const omittedRepoTasks =
omittedRepoRoots.length === 0
? []
: artifacts.manifest.skills.map(
: manifest.skills.map(
(current) => () =>
observeSkillFreshnessInstallation({
current,
currentAppVersion: args.currentAppVersion,
artifacts,
lockSource: lockSourceOf(current.name),
rootId: 'repo-scan-limit',
providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))],
sourceKind: 'repo',
@@ -156,8 +132,7 @@ export async function inventorySkillFreshness(args: {
classifyUnsupportedSkillCandidate({
root,
current,
currentAppVersion: args.currentAppVersion,
artifacts,
lockSource: lockSourceOf(current.name),
unresolvedPath: candidate.path,
candidateLstat
})
@@ -178,21 +153,16 @@ export async function inventorySkillFreshness(args: {
const installations = dedupeSkillFreshnessPlacements([
...homeInstallations,
...unsupportedInstallations
])
.map((installation) => trustLockInstalledRevision(installation, globalSkillLocks))
.sort(
(left, right) =>
left.name.localeCompare(right.name, 'en') ||
left.unresolvedPath.localeCompare(right.unresolvedPath, 'en')
)
]).sort(
(left, right) =>
left.name.localeCompare(right.name, 'en') ||
left.unresolvedPath.localeCompare(right.unresolvedPath, 'en')
)
return {
schemaVersion: 1,
installations,
eligibleUpdateNames: eligibleSkillUpdateNames(
installations,
convergableSkillNames(installations, globalSkillLocks, artifacts.knownSnapshots)
),
eligibleUpdateNames: eligibleSkillUpdateNames(installations),
scanIssues,
scannedAt: Date.now()
}
@@ -2,17 +2,10 @@ import type { Stats } from 'node:fs'
import { join } from 'node:path'
import type {
SkillCurrentBundleEntry,
SkillFreshnessInstallation,
SkillFreshnessStatus,
SkillKnownSnapshot
SkillFreshnessInstallation
} from '../../shared/skill-freshness'
import type { SkillScanRoot } from './skill-discovery-sources'
import type { SkillBundleArtifacts } from './skill-bundle-artifacts'
import {
matchingKnownSnapshot,
observeSkillPackage,
officialPathsGitTreeSha
} from './skill-package-identity'
import { matchesBundledSkillFiles, observeSkillPackage } from './skill-package-identity'
import {
classifyHomeSkillTopology,
classifyUnsupportedSkillTopology,
@@ -23,46 +16,13 @@ import {
export type CandidateLstat = (path: string) => Promise<Stats>
function freshnessStatus(
snapshot: SkillKnownSnapshot | null,
current: SkillCurrentBundleEntry
): SkillFreshnessStatus {
if (!snapshot) {
return 'unrecognized'
}
if (snapshot.releaseRevision > current.releaseRevision) {
return 'newer-known'
}
return snapshot.packageDigest === current.packageDigest ? 'current' : 'outdated'
}
function errorCategory(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
function isInaccessibleError(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'code' in error &&
(error.code === 'EACCES' || error.code === 'EPERM')
)
}
function knownSnapshots(
artifacts: SkillBundleArtifacts,
current: SkillCurrentBundleEntry
): SkillKnownSnapshot[] {
const snapshots = artifacts.knownSnapshots[current.name] ?? []
return snapshots.some((snapshot) => snapshot.packageDigest === current.packageDigest)
? snapshots
: [...snapshots, current]
}
export async function observeSkillFreshnessInstallation(args: {
current: SkillCurrentBundleEntry
currentAppVersion: string
artifacts: SkillBundleArtifacts
lockSource: string | null
rootId: string
providers: SkillFreshnessInstallation['providers']
sourceKind: SkillFreshnessInstallation['sourceKind']
@@ -81,56 +41,27 @@ export async function observeSkillFreshnessInstallation(args: {
resolvedPath: args.topology.resolvedPath,
physicalIdentity: args.topology.identity,
topology: args.topology.topology,
currentReleaseRevision: args.current.releaseRevision,
currentPackageDigest: args.current.packageDigest,
currentAppVersion: args.currentAppVersion,
lockSource: args.lockSource,
errorCategory: args.topology.errorCategory
}
if (!args.topology.resolvedPath || !args.topology.identity) {
return {
...base,
status: 'inaccessible',
installedReleaseRevision: null,
installedAppVersion: null,
observedPackageDigest: null,
observedGitTreeSha: null
}
return { ...base, status: 'inaccessible' }
}
try {
const observed = await observeSkillPackage(args.topology.resolvedPath)
const snapshots = knownSnapshots(args.artifacts, args.current)
const officialPaths = new Set(args.current.files.map((file) => file.path))
const matchedSnapshot = matchingKnownSnapshot(observed, snapshots, officialPaths)
// Why: a later release can reintroduce identical bytes. Exact current
// identity is still current, and cannot honestly be attributed to the later tag.
const snapshot =
observed.observedDigest === args.current.packageDigest ? args.current : matchedSnapshot
// Why: the only question is whether this is the version this build ships. Any
// difference, older release, unreleased commit or local edit alike, is outdated.
return {
...base,
status: freshnessStatus(snapshot, args.current),
installedReleaseRevision: snapshot?.releaseRevision ?? null,
// Why: the current revision may be unreleased (or trail the newest tag),
// so its label is the running build's version; only historical revisions
// resolve through the release mapping.
installedAppVersion: snapshot
? snapshot.releaseRevision === args.current.releaseRevision
? args.currentAppVersion
: (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ??
null)
: null,
observedPackageDigest: observed.observedDigest,
observedGitTreeSha: observed.observedGitTreeSha,
observedOfficialGitTreeSha: officialPathsGitTreeSha(observed, officialPaths)
status: matchesBundledSkillFiles(observed, args.current.files) ? 'current' : 'outdated'
}
} catch (error) {
// Why: a package Orca could not read is never offered for overwrite on a guess.
return {
...base,
status: isInaccessibleError(error) ? 'inaccessible' : 'unrecognized',
installedReleaseRevision: null,
installedAppVersion: null,
observedPackageDigest: null,
observedGitTreeSha: null,
status: 'inaccessible',
errorCategory: errorCategory(error, 'skill-package-read-failed')
}
}
@@ -139,8 +70,7 @@ export async function observeSkillFreshnessInstallation(args: {
export async function classifyHomeSkillCandidate(args: {
root: SkillScanRoot
current: SkillCurrentBundleEntry
currentAppVersion: string
artifacts: SkillBundleArtifacts
lockSource: string | null
canonicalRootPath: string
candidateLstat: CandidateLstat
}): Promise<SkillFreshnessInstallation | null> {
@@ -153,8 +83,7 @@ export async function classifyHomeSkillCandidate(args: {
}
return observeSkillFreshnessInstallation({
current: args.current,
currentAppVersion: args.currentAppVersion,
artifacts: args.artifacts,
lockSource: args.lockSource,
rootId: args.root.id,
providers: args.root.providers,
sourceKind: args.root.sourceKind,
@@ -182,8 +111,7 @@ export async function classifyHomeSkillCandidate(args: {
}
return observeSkillFreshnessInstallation({
current: args.current,
currentAppVersion: args.currentAppVersion,
artifacts: args.artifacts,
lockSource: args.lockSource,
rootId: args.root.id,
providers: args.root.providers,
sourceKind: args.root.sourceKind,
@@ -196,8 +124,7 @@ export async function classifyHomeSkillCandidate(args: {
export async function classifyUnsupportedSkillCandidate(args: {
root: SkillScanRoot
current: SkillCurrentBundleEntry
currentAppVersion: string
artifacts: SkillBundleArtifacts
lockSource: string | null
unresolvedPath: string
candidateLstat: CandidateLstat
}): Promise<SkillFreshnessInstallation | null> {
@@ -209,8 +136,7 @@ export async function classifyUnsupportedSkillCandidate(args: {
}
return observeSkillFreshnessInstallation({
current: args.current,
currentAppVersion: args.currentAppVersion,
artifacts: args.artifacts,
lockSource: args.lockSource,
rootId: args.root.id,
providers: args.root.providers,
sourceKind: args.root.sourceKind,
@@ -226,8 +152,7 @@ export async function classifyUnsupportedSkillCandidate(args: {
}
return observeSkillFreshnessInstallation({
current: args.current,
currentAppVersion: args.currentAppVersion,
artifacts: args.artifacts,
lockSource: args.lockSource,
rootId: args.root.id,
providers: args.root.providers,
sourceKind: args.root.sourceKind,
@@ -1,76 +0,0 @@
import { execFileSync } from 'node:child_process'
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { SkillBundleManifest } from '../../shared/skill-freshness'
import { observeSkillPackage } from './skill-package-identity'
const REPO_ROOT = resolve(__dirname, '..', '..', '..')
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
)
})
describe('observed skill git tree identity', () => {
// The decisive check: the runtime port must reproduce the generator's tree
// sha bit-for-bit, or the lock comparison never matches and the verdict fix
// is a silent no-op. The manifest's gitTreeSha values are generated from
// these same working-tree bytes.
it('matches the gitTreeSha the bundled manifest records for every shipped skill', async () => {
const manifest = JSON.parse(
await readFile(join(REPO_ROOT, 'resources', 'skills', 'current-manifest.json'), 'utf8')
) as SkillBundleManifest
expect(manifest.skills.length).toBeGreaterThan(0)
for (const skill of manifest.skills) {
const observed = await observeSkillPackage(join(REPO_ROOT, 'skills', skill.name))
expect(observed.observedGitTreeSha, skill.name).toBe(skill.gitTreeSha)
}
})
// Shipped skills are flat today, so this covers what they do not: nested
// directories, an executable, binary bytes, and the `z.txt` vs `z/` edge where
// git's slash-append ordering diverges from a plain name sort.
it('matches git write-tree over nesting, executables and the directory sort edge', async () => {
const base = await mkdtemp(join(tmpdir(), 'orca-skill-tree-sha-'))
temporaryDirectories.push(base)
const work = join(base, 'work')
const repoShell = join(base, 'repo')
await mkdir(join(work, 'z'), { recursive: true })
await mkdir(join(work, 'nested', 'deep'), { recursive: true })
await mkdir(repoShell, { recursive: true })
await writeFile(join(work, 'SKILL.md'), '---\nname: fixture\n---\n')
await writeFile(join(work, 'z.txt'), 'sorts before the z directory\n')
await writeFile(join(work, 'z', 'inner.md'), 'directory entry\n')
await writeFile(join(work, 'nested', 'deep', 'tool.sh'), '#!/bin/sh\nexit 0\n')
await chmod(join(work, 'nested', 'deep', 'tool.sh'), 0o755)
await writeFile(join(work, 'blob.bin'), Buffer.from([0, 1, 2, 253, 254, 255]))
const globalConfig = join(base, 'global.gitconfig')
const systemConfig = join(base, 'system.gitconfig')
await Promise.all([writeFile(globalConfig, ''), writeFile(systemConfig, '')])
const env = {
...process.env,
GIT_CONFIG_GLOBAL: globalConfig,
GIT_CONFIG_SYSTEM: systemConfig
}
execFileSync('git', ['init', '--quiet', repoShell], { env })
const gitDirArgs = ['--git-dir', join(repoShell, '.git'), '--work-tree', work]
execFileSync('git', [...gitDirArgs, '-c', 'core.autocrlf=false', 'add', '-A'], {
env,
cwd: work
})
const expected = execFileSync('git', [...gitDirArgs, 'write-tree'], {
env,
cwd: work,
encoding: 'utf8'
}).trim()
const observed = await observeSkillPackage(work)
expect(observed.observedGitTreeSha).toBe(expected)
})
})
@@ -1,82 +0,0 @@
import { createHash } from 'node:crypto'
/**
* Git object identity for an observed skill package.
*
* The updater's lock records the git tree sha of the skill folder it installed,
* so hashing the observed bytes the same way lets the post-run verdict recognise
* content the bundled registry has never seen. The algorithm is a port of
* `gitTreeSha` in config/scripts/generate-skill-bundle-manifest.mjs and must
* match it exactly — a near-miss never matches anything and silently disables
* the verdict's lock check.
*
* Hashes are over RAW bytes deliberately: a CRLF materialization (Windows
* checkout) hashes differently from the source tree, so it fails closed to the
* pre-lock-check behavior rather than misidentifying content.
*/
export type SkillGitTreeFileEntry = {
/** Slash-separated path relative to the package root. */
path: string
executable: boolean
blobSha: Buffer
}
type SkillGitTreeDirectory = {
directories: Map<string, SkillGitTreeDirectory>
files: { filename: string; executable: boolean; blobSha: Buffer }[]
}
function gitObjectSha(kind: 'blob' | 'tree', bytes: Buffer): Buffer {
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
}
export function gitBlobSha(bytes: Buffer): Buffer {
return gitObjectSha('blob', bytes)
}
export function skillPackageGitTreeSha(entries: readonly SkillGitTreeFileEntry[]): string {
const root: SkillGitTreeDirectory = { directories: new Map(), files: [] }
for (const entry of entries) {
const parts = entry.path.split('/')
const filename = parts.pop() as string
let directory = root
for (const part of parts) {
let child = directory.directories.get(part)
if (!child) {
child = { directories: new Map(), files: [] }
directory.directories.set(part, child)
}
directory = child
}
directory.files.push({ filename, executable: entry.executable, blobSha: entry.blobSha })
}
function hashDirectory(directory: SkillGitTreeDirectory): Buffer {
const children = [
...[...directory.directories].map(([name, child]) => ({
mode: '40000',
name,
sortKey: Buffer.from(`${name}/`),
hash: hashDirectory(child)
})),
...directory.files.map((file) => ({
mode: file.executable ? '100755' : '100644',
name: file.filename,
sortKey: Buffer.from(file.filename),
hash: file.blobSha
}))
].sort((left, right) => {
// Git orders tree entries as raw bytes with directory names read as `name/`.
return left.sortKey.compare(right.sortKey)
})
const body = Buffer.concat(
children.map(({ mode, name, hash }) =>
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
)
)
return gitObjectSha('tree', body)
}
return hashDirectory(root).toString('hex')
}
@@ -0,0 +1,67 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readSkillLockSources } from './skill-lock-sources'
const temporaryDirectories: string[] = []
async function temporaryRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-sources-'))
temporaryDirectories.push(root)
return root
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('skills.sh lock sources', () => {
it('reads each entry source regardless of how its hash was recorded', async () => {
const homeDir = await temporaryRoot()
await mkdir(join(homeDir, '.agents'), { recursive: true })
await writeFile(
join(homeDir, '.agents', '.skill-lock.json'),
JSON.stringify({
version: 3,
skills: {
orchestration: { skillFolderHash: 'a'.repeat(64), source: 'stablyai/orca' },
'computer-use': { source: 'acme/agent-skills' },
copied: {},
emptySource: { source: '' },
constructor: { source: 'stablyai/orca' }
}
})
)
await expect(readSkillLockSources({ homeDir, stateHome: null })).resolves.toEqual(
new Map([
['orchestration', 'stablyai/orca'],
['computer-use', 'acme/agent-skills'],
['constructor', 'stablyai/orca']
])
)
})
it('uses the XDG state lock when configured', async () => {
const root = await temporaryRoot()
const stateHome = join(root, 'state')
await mkdir(join(stateHome, 'skills'), { recursive: true })
await writeFile(
join(stateHome, 'skills', '.skill-lock.json'),
JSON.stringify({ version: 3, skills: { 'orca-cli': { source: 'stablyai/orca' } } })
)
await expect(readSkillLockSources({ homeDir: root, stateHome })).resolves.toEqual(
new Map([['orca-cli', 'stablyai/orca']])
)
})
it('reads no sources from a missing or malformed lock', async () => {
const homeDir = await temporaryRoot()
await expect(readSkillLockSources({ homeDir, stateHome: null })).resolves.toEqual(new Map())
await mkdir(join(homeDir, '.agents'), { recursive: true })
await writeFile(join(homeDir, '.agents', '.skill-lock.json'), '{"skills": []}')
await expect(readSkillLockSources({ homeDir, stateHome: null })).resolves.toEqual(new Map())
})
})
+52
View File
@@ -0,0 +1,52 @@
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
type SkillLockSourcesArgs = {
homeDir?: string
stateHome?: string | null
}
function globalSkillLockPath(args: SkillLockSourcesArgs): string {
const stateHome =
args.stateHome === undefined
? args.homeDir === undefined
? (process.env.XDG_STATE_HOME ?? null)
: null
: args.stateHome
return stateHome
? join(stateHome, 'skills', '.skill-lock.json')
: join(args.homeDir ?? homedir(), '.agents', '.skill-lock.json')
}
/**
* Each globally installed skill's recorded `source`, keyed by name.
*
* Only provenance is read. The lock's hash depends on whether the skills CLI reached
* GitHub's API when it installed, so it says nothing reliable about the bytes on disk.
*/
export async function readSkillLockSources(
args: SkillLockSourcesArgs = {}
): Promise<ReadonlyMap<string, string>> {
try {
const parsed: unknown = JSON.parse(await readFile(globalSkillLockPath(args), 'utf8'))
const skills =
parsed && typeof parsed === 'object' && 'skills' in parsed ? parsed.skills : undefined
if (!skills || typeof skills !== 'object' || Array.isArray(skills)) {
return new Map()
}
return new Map(
Object.entries(skills).flatMap(([name, entry]: [string, unknown]) =>
entry &&
typeof entry === 'object' &&
'source' in entry &&
typeof entry.source === 'string' &&
entry.source.length > 0
? [[name, entry.source] as const]
: []
)
)
} catch {
return new Map()
}
}
+21 -162
View File
@@ -1,16 +1,14 @@
import { execFile } from 'node:child_process'
import { createServer } from 'node:net'
import { chmod, cp, link, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { chmod, link, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import {
describeObservedSkillFile,
matchingKnownSnapshot,
observeSkillPackage,
officialPathsGitTreeSha,
skillPackageDigest
matchesBundledSkillFiles,
observeSkillPackage
} from './skill-package-identity'
const temporaryDirectories: string[] = []
@@ -22,19 +20,12 @@ async function temporarySkill(): Promise<string> {
return root
}
/** A byte-identical folder elsewhere, so a hash can be derived without the original. */
async function copyOf(source: string): Promise<string> {
const target = await temporarySkill()
await cp(source, target, { recursive: true })
return target
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('skill package identity', () => {
it('matches CRLF installed text to an LF official snapshot', async () => {
it('matches CRLF installed text to LF bundled files', async () => {
const root = await temporarySkill()
await writeFile(join(root, 'SKILL.md'), 'first\r\nsecond\r\n')
const observed = await observeSkillPackage(root)
@@ -43,20 +34,7 @@ describe('skill package identity', () => {
// Why: scans can observe several package byte budgets concurrently; only
// hashes, not raw file buffers, should survive each file's identity pass.
expect(observed.files[0]).not.toHaveProperty('bytes')
expect(
matchingKnownSnapshot(
observed,
[
{
releaseRevision: 1,
packageDigest: skillPackageDigest([expected]),
gitTreeSha: 'tree',
files: [expected]
}
],
new Set(['SKILL.md'])
)?.releaseRevision
).toBe(1)
expect(matchesBundledSkillFiles(observed, [expected])).toBe(true)
})
it('uses exact bytes for executable and binary files', async () => {
@@ -149,16 +127,14 @@ describe('skill package identity', () => {
}
})
it('ignores OS-authored sidecars so a browsed folder still matches its snapshot', async () => {
it('ignores OS-authored sidecars so a browsed folder still matches the bundle', async () => {
const pristine = await temporarySkill()
await writeFile(join(pristine, 'SKILL.md'), 'skill\n')
const official = await observeSkillPackage(pristine)
const browsed = await temporarySkill()
await writeFile(join(browsed, 'SKILL.md'), 'skill\n')
// Every name the OS writes on its own, including one that sorts BEFORE SKILL.md —
// the index-aligned comparison in matchingKnownSnapshot misaligns on a leading entry,
// so a trailing-name-only fixture would pass while the reported bug survived.
// Every name the OS writes on its own, including one that sorts BEFORE SKILL.md.
await writeFile(join(browsed, '.DS_Store'), Buffer.from([0, 1, 2, 3]))
await writeFile(join(browsed, '._SKILL.md'), Buffer.from([0, 5]))
await writeFile(join(browsed, 'Thumbs.db'), Buffer.from([9]))
@@ -167,23 +143,8 @@ describe('skill package identity', () => {
const observed = await observeSkillPackage(browsed)
expect(observed.files.map((file) => file.path)).toEqual(['SKILL.md'])
// The lock-trust path compares this against the updater's recorded source tree, so it
// has to come out clean too, not just the digest.
expect(observed.observedGitTreeSha).toBe(official.observedGitTreeSha)
expect(
matchingKnownSnapshot(
observed,
[
{
releaseRevision: 1,
packageDigest: official.observedDigest,
gitTreeSha: official.observedGitTreeSha,
files: official.files
}
],
new Set(['SKILL.md'])
)?.releaseRevision
).toBe(1)
expect(observed.observedDigest).toBe(official.observedDigest)
expect(matchesBundledSkillFiles(observed, official.files)).toBe(true)
})
it('keeps guarding a directory or link that only wears an OS metadata name', async () => {
@@ -216,130 +177,28 @@ describe('skill package identity', () => {
await writeFile(join(edited, '.DS_Store'), Buffer.from([0, 1]))
// Extra sidecar next to an untouched SKILL.md must still match — agent CLIs
// write agents/openai.yaml (and similar) without editing official bytes.
// Content drift on a listed file remains fail-closed.
const withPayload = await temporarySkill()
await writeFile(join(withPayload, 'SKILL.md'), 'skill\n')
await mkdir(join(withPayload, 'agents'), { recursive: true })
await writeFile(join(withPayload, 'agents', 'openai.yaml'), 'display_name: test\n')
const snapshot = [
{
releaseRevision: 1,
packageDigest: official.observedDigest,
gitTreeSha: official.observedGitTreeSha,
files: official.files
}
]
const official1 = new Set(['SKILL.md'])
expect(matchingKnownSnapshot(await observeSkillPackage(edited), snapshot, official1)).toBeNull()
expect(
matchingKnownSnapshot(await observeSkillPackage(withPayload), snapshot, official1)
?.releaseRevision
).toBe(1)
})
it('does not let an older revision launder drift on a file the current one lists', async () => {
// Subset matching treats a path the tested revision does not list as a neighbour.
// Left unguarded, revision 1 (SKILL.md alone) would match a revision-2 folder whose
// references/x.md had been rewritten — reporting a tampered package as merely
// outdated, with an update offered and no "may be modified" anywhere.
const pristine = await temporarySkill()
await writeFile(join(pristine, 'SKILL.md'), 'skill\n')
const revisionOne = await observeSkillPackage(pristine)
const tampered = await temporarySkill()
await writeFile(join(tampered, 'SKILL.md'), 'skill\n')
await mkdir(join(tampered, 'references'), { recursive: true })
await writeFile(join(tampered, 'references', 'x.md'), 'attacker content\n')
const snapshots = [
{
releaseRevision: 1,
packageDigest: revisionOne.observedDigest,
gitTreeSha: revisionOne.observedGitTreeSha,
files: revisionOne.files
}
]
const observed = await observeSkillPackage(tampered)
expect(
matchingKnownSnapshot(observed, snapshots, new Set(['SKILL.md', 'references/x.md']))
).toBeNull()
// The same folder is a plain sidecar case once the current bundle stops claiming
// that path, so the guard must key on what is official — not on file count.
expect(matchingKnownSnapshot(observed, snapshots, new Set(['SKILL.md']))?.releaseRevision).toBe(
1
expect(matchesBundledSkillFiles(await observeSkillPackage(edited), official.files)).toBe(false)
expect(matchesBundledSkillFiles(await observeSkillPackage(withPayload), official.files)).toBe(
true
)
})
it('hashes official paths for the lock without hiding an upstream file it added', async () => {
const clean = await temporarySkill()
await writeFile(join(clean, 'SKILL.md'), 'skill\n')
const source = await observeSkillPackage(clean)
it('reports a missing bundled file as unmatched', async () => {
const shipped = await temporarySkill()
await writeFile(join(shipped, 'SKILL.md'), 'skill\n')
await mkdir(join(shipped, 'references'), { recursive: true })
await writeFile(join(shipped, 'references', 'x.md'), 'reference\n')
const official = await observeSkillPackage(shipped)
const withSidecar = await temporarySkill()
await writeFile(join(withSidecar, 'SKILL.md'), 'skill\n')
await mkdir(join(withSidecar, 'agents'), { recursive: true })
await writeFile(join(withSidecar, 'agents', 'openai.yaml'), 'display_name: test\n')
const sidecarObserved = await observeSkillPackage(withSidecar)
const officialPaths = new Set(['SKILL.md'])
const partial = await temporarySkill()
await writeFile(join(partial, 'SKILL.md'), 'skill\n')
// A sidecar folder reaches the lock's source-tree hash only once scoped.
expect(sidecarObserved.observedGitTreeSha).not.toBe(source.observedGitTreeSha)
expect(officialPathsGitTreeSha(sidecarObserved, officialPaths)).toBe(source.observedGitTreeSha)
// An upstream revision that ADDS a file puts it in the lock's own tree, so the
// whole-folder hash has to survive alongside — scoping it away would re-break the
// clean install this check exists to recognise (#11220).
const upstream = await temporarySkill()
await writeFile(join(upstream, 'SKILL.md'), 'skill\n')
await mkdir(join(upstream, 'references'), { recursive: true })
await writeFile(join(upstream, 'references', 'new.md'), 'shipped upstream\n')
const upstreamObserved = await observeSkillPackage(upstream)
// The lock is the source tree the CLI installed, derived independently of the
// observation under test — comparing the observation to itself would assert nothing.
const upstreamLock = (await observeSkillPackage(await copyOf(upstream))).observedGitTreeSha
expect(officialPathsGitTreeSha(upstreamObserved, officialPaths)).not.toBe(upstreamLock)
expect(upstreamObserved.observedGitTreeSha).toBe(upstreamLock)
})
it('scopes the lock hash to the current bundle, not every path ever shipped', async () => {
// A file an older revision shipped and the current one dropped is a stale leftover,
// not part of what the updater installed. Scoping to the union of all revisions would
// drag it back into the hash purely because its name was once official, and the copy
// would read "may be modified" over bytes the CLI itself wrote.
const source = await temporarySkill()
await writeFile(join(source, 'SKILL.md'), 'skill\n')
const lock = (await observeSkillPackage(source)).observedGitTreeSha
const withLeftover = await temporarySkill()
await writeFile(join(withLeftover, 'SKILL.md'), 'skill\n')
await mkdir(join(withLeftover, 'references'), { recursive: true })
await writeFile(join(withLeftover, 'references', 'legacy.md'), 'dropped after rev 1\n')
const observed = await observeSkillPackage(withLeftover)
expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).toBe(lock)
// The union of every revision's paths — what scoping must NOT use.
expect(
officialPathsGitTreeSha(observed, new Set(['SKILL.md', 'references/legacy.md']))
).not.toBe(lock)
})
it('does not hand the lock an empty tree when no official path is present', async () => {
// git's empty-tree sha is a real, matchable value; returning it would let a lock that
// ever recorded an empty source tree vouch for any folder holding nothing official.
const root = await temporarySkill()
await mkdir(join(root, 'agents'), { recursive: true })
await writeFile(join(root, 'agents', 'openai.yaml'), 'display_name: test\n')
const observed = await observeSkillPackage(root)
expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).toBe(
observed.observedGitTreeSha
)
expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).not.toBe(
'4b825dc642cb6eb9a060e54bf8d69288fbee4904'
)
expect(matchesBundledSkillFiles(await observeSkillPackage(partial), official.files)).toBe(false)
})
it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => {
+14 -88
View File
@@ -2,25 +2,13 @@ import { createHash } from 'node:crypto'
import type { Dirent } from 'node:fs'
import { lstat, open, opendir } from 'node:fs/promises'
import { isAbsolute, join, relative, sep } from 'node:path'
import type { SkillBundleFileIdentity, SkillKnownSnapshot } from '../../shared/skill-freshness'
import {
gitBlobSha,
skillPackageGitTreeSha,
type SkillGitTreeFileEntry
} from './skill-git-tree-identity'
import type { SkillBundleFileIdentity } from '../../shared/skill-freshness'
type ObservedSkillFile = SkillBundleFileIdentity
export type ObservedSkillPackage = {
files: ObservedSkillFile[]
observedDigest: string
/**
* Git tree sha of every observed file's raw bytes — one of the two values comparable
* against the updater lock's `skillFolderHash` (see `officialPathsGitTreeSha`).
*/
observedGitTreeSha: string
/** Retained so a subset of the observed paths can be re-hashed the same way. */
treeEntries: SkillGitTreeFileEntry[]
}
export function observedSkillPackagesMatch(
@@ -45,9 +33,8 @@ export function observedSkillPackagesMatch(
// Why: package identity compares a live user directory against a tree the generator read
// from a clean checkout, so anything the OS deposits on its own counts as drift the user
// never caused. One Finder visit writes .DS_Store, and that alone made the copy
// 'unrecognized' — reported as "may be modified", left out of the update, and unfixable by
// running it, since the updater compares its lock to the source and never reads disk.
// never caused. One Finder visit writes .DS_Store, and that alone would mark the copy
// outdated.
//
// Only OS-authored names belong here. Tolerating unexpected files in general would let a
// modified skill pass: the entry is safe precisely because an official SKILL.md never
@@ -197,7 +184,6 @@ export async function observeSkillPackage(
inferShebangExecutables = false
): Promise<ObservedSkillPackage> {
const files: ObservedSkillFile[] = []
const treeEntries: SkillGitTreeFileEntry[] = []
const caseFoldedPaths = new Map<string, string>()
const normalizedExecutablePaths =
platform === 'win32' && executablePaths
@@ -290,7 +276,6 @@ export async function observeSkillPackage(
? bytes.subarray(0, 2).equals(Buffer.from('#!'))
: (fileStat.mode & 0o111) !== 0
files.push(describeObservedSkillFile(manifestPath, bytes, executable))
treeEntries.push({ path: manifestPath, executable, blobSha: gitBlobSha(bytes) })
} else {
throw new Error('skill-package-special-file')
}
@@ -298,81 +283,22 @@ export async function observeSkillPackage(
}
await visit(packageRoot, 0)
return {
files,
observedDigest: skillPackageDigest(files),
observedGitTreeSha: skillPackageGitTreeSha(treeEntries),
treeEntries
}
return { files, observedDigest: skillPackageDigest(files) }
}
/**
* The newest revision whose every listed file is present and byte-identical.
* Whether every file the bundled entry lists is present with the same identity.
*
* `officialPaths` is what the CURRENT bundle says this skill owns, and it is what
* separates a tolerable neighbour from drift. Agent CLIs drop their own metadata
* beside an official SKILL.md (Codex writes `agents/openai.yaml` and cannot put it
* anywhere else), and a file no revision claims is not evidence the user edited
* anything — so extras alone must not mark the package unrecognized.
*
* A file the current bundle DOES list is never an extra, even when the revision
* being tested predates it: without that guard an older snapshot would happily match
* a folder whose newer official file had been tampered with, laundering real drift
* into a clean "outdated" row. Listed-file drift still fails closed either way.
* Files the entry does not list are ignored: agent CLIs drop their own metadata beside an
* official SKILL.md (Codex writes `agents/openai.yaml`), and that is not drift.
*/
export function matchingKnownSnapshot(
export function matchesBundledSkillFiles(
observed: ObservedSkillPackage,
snapshots: readonly SkillKnownSnapshot[],
officialPaths: ReadonlySet<string>
): SkillKnownSnapshot | null {
expectedFiles: readonly SkillBundleFileIdentity[]
): boolean {
const observedByPath = new Map(observed.files.map((file) => [file.path, file]))
for (let index = snapshots.length - 1; index >= 0; index -= 1) {
const snapshot = snapshots[index]
if (!snapshot) {
continue
}
const listed = new Set(snapshot.files.map((file) => file.path))
const launders = observed.files.some(
(file) => !listed.has(file.path) && officialPaths.has(file.path)
)
if (
!launders &&
snapshot.files.every((expected) => {
const actual = observedByPath.get(expected.path)
return Boolean(actual && matchesFileIdentity(actual, expected))
})
) {
return snapshot
}
}
return null
}
/**
* The observed folder hashed as if it held only the files the current bundle owns.
*
* Compared against the updater lock's `skillFolderHash` ALONGSIDE the whole-folder
* hash, never instead of it. The two cover different halves and neither subsumes the
* other: the lock records the source tree, so a folder carrying a sidecar only ever
* matches scoped, while an upstream revision that ADDS a file puts that file in the
* lock's own tree and only ever matches whole. Publishing one and dropping the other
* would trade this bug for #11220 — a clean install reading "may be modified" and its
* update run reporting failure.
*
* Scoped to the CURRENT entry rather than every revision ever shipped: a leftover from
* an older revision is exactly the stale byte the lock comparison should look past, and
* unioning historical paths would drag it back in on the accident of its name.
*
* A folder holding none of them yields the whole-folder hash rather than git's empty-tree
* sha, which is a real, matchable value: a lock that ever recorded an empty source tree
* would otherwise vouch for every such folder.
*/
export function officialPathsGitTreeSha(
observed: ObservedSkillPackage,
officialPaths: ReadonlySet<string>
): string {
const scoped = observed.treeEntries.filter((entry) => officialPaths.has(entry.path))
return scoped.length === 0 || scoped.length === observed.treeEntries.length
? observed.observedGitTreeSha
: skillPackageGitTreeSha(scoped)
return expectedFiles.every((expected) => {
const actual = observedByPath.get(expected.path)
return Boolean(actual && matchesFileIdentity(actual, expected))
})
}
@@ -1,202 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { SkillFreshnessInstallation, SkillKnownSnapshot } from '../../shared/skill-freshness'
import { convergableSkillNames } from './skill-update-convergence'
// `installedReleaseRevision` is the revision observation resolved this copy to, and
// null is the honest "readable, but matching nothing we know". A null
// `observedPackageDigest` is the different case of a copy that could not be read.
//
// The digest defaults to the resolved revision's own, so these fixtures describe a
// folder holding nothing but official files. That keeps the revert signal honest:
// swapping resolution back to a digest lookup still passes every case below except
// the sidecar one, which is the only case that distinguishes the two mechanisms.
function placement(
name: string,
installedReleaseRevision: number | null,
topology: SkillFreshnessInstallation['topology'] = 'canonical-copy',
observedPackageDigest: string | null = `digest-${installedReleaseRevision}`
): SkillFreshnessInstallation {
return {
id: `${name}:${installedReleaseRevision}:${topology}`,
name,
rootId: 'home',
providers: [],
sourceKind: 'home',
sourceLabel: 'home',
unresolvedPath: `~/.agents/skills/${name}`,
resolvedPath: `/home/u/.agents/skills/${name}`,
physicalIdentity: '1:1',
topology,
status: 'outdated',
installedReleaseRevision,
installedAppVersion: null,
currentReleaseRevision: 8,
currentPackageDigest: 'digest-current',
currentAppVersion: '1.4.160',
observedPackageDigest,
errorCategory: null
}
}
function revision(releaseRevision: number, gitTreeSha: string): SkillKnownSnapshot {
return { releaseRevision, packageDigest: `digest-${releaseRevision}`, gitTreeSha, files: [] }
}
const PRE_STUB = revision(1, 'f3727995')
const STUB = revision(2, '091d9bcc')
describe('convergableSkillNames', () => {
// The real reported case: the lock records the stub tree (091d9bcc) while disk
// still holds the pre-stub revision (f3727995). `skills update` compares lock to
// source, sees no work, exits 0 and writes nothing — forever.
it('drops a skill whose lock records a revision the disk does not have', () => {
const result = convergableSkillNames(
[placement('orca-linear', 1)],
new Map([['orca-linear', '091d9bcc']]),
{ 'orca-linear': [PRE_STUB, STUB] }
)
expect([...result]).toEqual([])
})
// The legitimate case that must NOT be gated: lock and disk agree, and the source
// has simply moved ahead of what this build bundles. The update really can converge.
it('keeps a skill whose lock matches disk even when it is outdated', () => {
const result = convergableSkillNames(
[placement('orca-cli', 1)],
new Map([['orca-cli', 'aaaa1111']]),
{ 'orca-cli': [revision(1, 'aaaa1111')] }
)
expect([...result]).toEqual(['orca-cli'])
})
it('keeps a skill whose disk content matches no known revision', () => {
const result = convergableSkillNames(
[placement('orca-cli', null)],
new Map([['orca-cli', 'aaaa1111']]),
{ 'orca-cli': [revision(1, 'bbbb2222')] }
)
expect([...result]).toEqual(['orca-cli'])
})
it('keeps a skill with no observable placement', () => {
const result = convergableSkillNames(
[placement('orca-cli', null, 'canonical-copy', null)],
new Map([['orca-cli', 'aaaa1111']]),
{ 'orca-cli': [revision(1, 'aaaa1111')] }
)
expect([...result]).toEqual(['orca-cli'])
})
// Why: a sidecar an agent CLI dropped beside the official files makes the folder
// digest match no revision at all, so resolving disk content by that digest would
// read as unidentifiable and quietly re-arm the unwinnable update. Observation
// already placed this copy at the pre-stub revision; the gate must honour that.
it('drops a stale skill whose folder holds files no revision lists', () => {
const result = convergableSkillNames(
[placement('orca-linear', 1, 'canonical-copy', 'digest-with-sidecar')],
new Map([['orca-linear', '091d9bcc']]),
{ 'orca-linear': [PRE_STUB, STUB] }
)
expect([...result]).toEqual([])
})
// One placement still matching the lock means the command has an anchor to write.
it('keeps a skill when any placement still matches the lock', () => {
const result = convergableSkillNames(
[placement('orca-cli', 1), placement('orca-cli', 2)],
new Map([['orca-cli', 'aaaa1111']]),
{ 'orca-cli': [revision(1, 'aaaa1111'), revision(2, 'f3727995')] }
)
expect([...result]).toEqual(['orca-cli'])
})
// A lock hash we cannot place is not evidence the command is stuck.
it('keeps a skill whose lock names no revision we know', () => {
const result = convergableSkillNames(
[placement('orca-cli', 1)],
new Map([['orca-cli', 'not-a-known-tree']]),
{ 'orca-cli': [revision(1, 'f3727995')] }
)
expect([...result]).toEqual(['orca-cli'])
})
// Why: `diskTreeShas` silently drops placements that resolved to nothing, so a
// stale copy sitting beside an unidentifiable one must NOT gate the name — the
// unknown half could be anything, including a copy the command would converge.
it('keeps a skill when one placement is stale but another is unidentifiable', () => {
const result = convergableSkillNames(
[placement('orca-cli', 1), placement('orca-cli', null)],
new Map([['orca-cli', '091d9bcc']]),
{ 'orca-cli': [PRE_STUB, STUB] }
)
expect([...result]).toEqual(['orca-cli'])
})
// Why: copies the command never writes must not defeat the gate. An
// unidentifiable plugin-cache repack would otherwise read as an unresolved
// placement and re-arm the unwinnable update on the drifted canonical.
it('ignores an unidentifiable plugin-cache copy when judging the canonical', () => {
const result = convergableSkillNames(
[placement('orca-linear', 1), placement('orca-linear', null, 'plugin-cache')],
new Map([['orca-linear', '091d9bcc']]),
{ 'orca-linear': [PRE_STUB, STUB] }
)
expect([...result]).toEqual([])
})
// A cache copy parked at the lock's own revision is not an anchor either —
// the command only writes the canonical, which is still drifted.
it('ignores a plugin-cache copy that matches the lock', () => {
const result = convergableSkillNames(
[placement('orca-linear', 1), placement('orca-linear', 2, 'plugin-cache')],
new Map([['orca-linear', '091d9bcc']]),
{ 'orca-linear': [PRE_STUB, STUB] }
)
expect([...result]).toEqual([])
})
it('judges each locked skill independently', () => {
const result = convergableSkillNames(
[placement('orca-linear', 1), placement('orca-cli', 1)],
new Map([
['orca-linear', '091d9bcc'],
['orca-cli', 'aaaa1111']
]),
{
'orca-linear': [PRE_STUB, STUB],
'orca-cli': [revision(1, 'aaaa1111')]
}
)
expect([...result]).toEqual(['orca-cli'])
})
// A skill directory can legitimately be named `constructor`, and lock names come
// straight off disk, so the snapshot lookup must not walk Object.prototype.
it('keeps a skill named after an Object prototype key eligible instead of throwing', () => {
for (const name of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
expect([
...convergableSkillNames([placement(name, 1)], new Map([[name, '091d9bcc']]), {})
]).toEqual([name])
}
})
it('indexes placements once across many independent locked skills', () => {
let nameReads = 0
const installations = Array.from({ length: 1000 }, (_, index) => ({
...placement(`skill-${index}`, index % 2 ? 2 : 1),
get name() {
nameReads++
return `skill-${index}`
}
}))
const locks = new Map(installations.map((entry) => [entry.name, STUB.gitTreeSha!]))
const snapshots = Object.fromEntries([...locks.keys()].map((name) => [name, [PRE_STUB, STUB]]))
nameReads = 0
expect([...convergableSkillNames(installations, locks, snapshots)]).toEqual(
[...locks.keys()].filter((_, index) => index % 2 === 1)
)
expect(nameReads).toBeLessThanOrEqual(1000)
nameReads = 0
expect([...convergableSkillNames(installations, new Map(), snapshots)]).toEqual([])
expect(nameReads).toBe(0)
})
})
@@ -1,97 +0,0 @@
import {
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES,
type SkillFreshnessInstallation,
type SkillKnownSnapshot
} from '../../shared/skill-freshness'
/**
* Names `skills update` can still move, judged against what it believes it installed.
*
* The updater decides what to do by comparing its lock's `skillFolderHash` against
* the source tree; it never reads disk. So once the lock records a revision the
* filesystem does not actually have, the command reports "up to date", exits 0 and
* writes nothing — no retry converges it. Offering an update there promises work the
* command cannot do, which is exactly what `eligibleSkillUpdateNames` exists to avoid.
*
* The honest signal is the lock's hash versus the `gitTreeSha` of the revision the
* DISK hashes to. Deliberately not versus the bundled manifest: the updater pulls from
* the source repo, which legitimately runs ahead of what this build ships, and gating
* on the bundle would withhold real updates. When lock and disk agree, an update is
* still genuinely available and stays on offer.
*
* Unknown either way (no lock entry, or disk content matching no known revision) is
* left eligible — silence is not evidence the command is stuck.
*/
export function convergableSkillNames(
installations: readonly SkillFreshnessInstallation[],
globalSkillLocks: ReadonlyMap<string, string>,
knownSnapshots: Readonly<Record<string, SkillKnownSnapshot[]>>
): ReadonlySet<string> {
const convergable = new Set(globalSkillLocks.keys())
if (convergable.size === 0) {
return convergable
}
const observableByName = new Map<string, SkillFreshnessInstallation[]>()
for (const entry of installations) {
const name = entry.name
if (
!globalSkillLocks.has(name) ||
!SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) ||
!entry.observedPackageDigest
) {
continue
}
const entries = observableByName.get(name)
if (entries) {
entries.push(entry)
} else {
observableByName.set(name, [entry])
}
}
for (const [name, lockHash] of globalSkillLocks) {
// Why: judged only over the placements the command writes, like eligibility
// itself. A plugin-cache or repo copy is never the command's to converge, so
// it must neither gate the name nor rescue it — an unidentifiable cache copy
// (or one parked at the lock's own revision) would otherwise defeat the gate
// and re-arm the unwinnable update.
const observable = observableByName.get(name) ?? []
if (observable.length === 0) {
continue
}
// `Object.hasOwn`: names come from an on-disk lock file, so a skill called
// `constructor` would otherwise read a function off the prototype and throw.
const revisions = (Object.hasOwn(knownSnapshots, name) && knownSnapshots[name]) || []
// Why: the revision each placement resolved to during observation, not a fresh
// lookup by whole-folder digest. Identity tolerates files the manifest never
// listed, so a folder holding an agent CLI's sidecar digests to nothing any
// revision knows — re-deriving here would call every such placement
// unidentifiable and quietly retire the gate for the users who most need it.
const diskTreeShas = observable
.map(
(entry) =>
revisions.find((revision) => revision.releaseRevision === entry.installedReleaseRevision)
?.gitTreeSha
)
.filter((sha): sha is string => Boolean(sha))
// Why: only claim the lock is stale when BOTH sides are positively identified —
// the lock names a revision we know, and every placement resolves to a different
// known revision. A lock hash we cannot place (a source we do not bundle, a
// revision older than the registry) is not evidence of anything, and gating on it
// would withhold updates that would have worked.
const lockNamesAKnownRevision = revisions.some((entry) => entry.gitTreeSha === lockHash)
// `diskTreeShas` drops placements that resolved to no known revision, so requiring
// every observable one to resolve is what keeps `every` honest: without it, one
// stale copy beside one unidentifiable copy would gate the name off the resolved
// half alone, contradicting the unknown-stays-eligible rule above.
const everyPlacementResolved = diskTreeShas.length === observable.length
if (
lockNamesAKnownRevision &&
everyPlacementResolved &&
diskTreeShas.length > 0 &&
diskTreeShas.every((sha) => sha !== lockHash)
) {
convergable.delete(name)
}
}
return convergable
}
+23 -190
View File
@@ -1,24 +1,15 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import type {
SkillFreshnessInstallation,
SkillFreshnessStatus,
SkillInstallationTopology
} from '../../shared/skill-freshness'
import { inventorySkillFreshness } from './skill-freshness-inventory'
import { observeSkillPackage } from './skill-package-identity'
import { readGloballyUpdatableSkillLocks } from './skill-update-registration'
import { skillUpdateFailedNames } from './skill-update-outcome'
const noLocks = new Map<string, string>()
function placement(
name: string,
status: SkillFreshnessStatus,
topology: SkillInstallationTopology = 'canonical-copy',
observedGitTreeSha: string | null = null
topology: SkillInstallationTopology = 'canonical-copy'
): SkillFreshnessInstallation {
return {
id: `${name}-${topology}-${status}`,
@@ -32,71 +23,56 @@ function placement(
physicalIdentity: `physical-${name}`,
topology,
status,
installedReleaseRevision: 2,
installedAppVersion: '2.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'current',
observedGitTreeSha,
lockSource: 'stablyai/orca',
errorCategory: null
}
}
describe('skillUpdateFailedNames', () => {
it('treats a convergent copy that is now current as landed', () => {
expect(
skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'current')], noLocks)
).toEqual([])
expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'current')])).toEqual([])
})
it('reports a copy the run left outdated', () => {
expect(
skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')], noLocks)
).toEqual(['orca-cli'])
})
it('reports a half-written bundle instead of reading it as success', () => {
// The old "still eligible?" test passed here: an unrecognized copy is not
// eligible either, so a corrupt write looked identical to a clean update.
expect(
skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'unrecognized')], noLocks)
).toEqual(['orca-cli'])
expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')])).toEqual([
'orca-cli'
])
})
it('reports an unreadable copy', () => {
expect(
skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'inaccessible')], noLocks)
).toEqual(['orca-cli'])
expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'inaccessible')])).toEqual([
'orca-cli'
])
})
it('reports a skill the run removed outright', () => {
expect(skillUpdateFailedNames(['orca-cli'], [], noLocks)).toEqual(['orca-cli'])
})
it('accepts a revision newer than this build ships', () => {
// The CLI pulls from the source repo, which runs ahead of the bundled manifest.
expect(
skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'newer-known')], noLocks)
).toEqual([])
expect(skillUpdateFailedNames(['orca-cli'], [])).toEqual(['orca-cli'])
})
it('ignores placements the update command never writes to', () => {
expect(
skillUpdateFailedNames(
['orca-cli'],
[placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'plugin-cache')],
noLocks
[placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'plugin-cache')]
)
).toEqual([])
})
it('fails the name when the run leaves a broken link behind', () => {
expect(
skillUpdateFailedNames(
['orca-cli'],
[placement('orca-cli', 'current'), placement('orca-cli', 'inaccessible', 'broken-link')]
)
).toEqual(['orca-cli'])
})
it('fails the name when any convergent alias was left behind', () => {
expect(
skillUpdateFailedNames(
['orca-cli'],
[placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'provider-alias')],
noLocks
[placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'provider-alias')]
)
).toEqual(['orca-cli'])
})
@@ -105,151 +81,8 @@ describe('skillUpdateFailedNames', () => {
expect(
skillUpdateFailedNames(
['orca-cli', 'orchestration'],
[placement('orca-cli', 'current'), placement('orchestration', 'outdated')],
noLocks
[placement('orca-cli', 'current'), placement('orchestration', 'outdated')]
)
).toEqual(['orchestration'])
})
it('treats unrecognized content whose tree sha matches the lock as landed', () => {
// Source-repo HEAD routinely runs ahead of the bundled registry; the lock is
// the CLI's own record of what it wrote.
expect(
skillUpdateFailedNames(
['orca-cli'],
[placement('orca-cli', 'unrecognized', 'canonical-copy', 'ahead-of-bundle')],
new Map([['orca-cli', 'ahead-of-bundle']])
)
).toEqual([])
})
it('still reports unrecognized content whose bytes do not match the lock', () => {
expect(
skillUpdateFailedNames(
['orca-cli'],
[placement('orca-cli', 'unrecognized', 'canonical-copy', 'half-written-bytes')],
new Map([['orca-cli', 'ahead-of-bundle']])
)
).toEqual(['orca-cli'])
})
it('still reports unrecognized content when the skill has no lock entry', () => {
expect(
skillUpdateFailedNames(
['orca-cli'],
[placement('orca-cli', 'unrecognized', 'canonical-copy', 'ahead-of-bundle')],
noLocks
)
).toEqual(['orca-cli'])
})
it('never forgives an outdated copy, even at the lock hash', () => {
// Lock == disk on an outdated copy means the command provably wrote nothing.
expect(
skillUpdateFailedNames(
['orca-cli'],
[placement('orca-cli', 'outdated', 'canonical-copy', 'locked-revision')],
new Map([['orca-cli', 'locked-revision']])
)
).toEqual(['orca-cli'])
})
it('does not let a lock-matching canonical copy excuse a degraded alias', () => {
expect(
skillUpdateFailedNames(
['orca-cli'],
[
placement('orca-cli', 'unrecognized', 'canonical-copy', 'ahead-of-bundle'),
placement('orca-cli', 'inaccessible', 'provider-alias')
],
new Map([['orca-cli', 'ahead-of-bundle']])
)
).toEqual(['orca-cli'])
})
})
describe('skillUpdateFailedNames over a real inventory', () => {
const repoRoot = resolve(__dirname, '..', '..', '..')
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
)
})
async function postCutFixture(): Promise<{ homeDir: string; installedTreeSha: string }> {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-outcome-'))
temporaryDirectories.push(root)
const homeDir = join(root, 'home')
const skillDir = join(homeDir, '.agents', 'skills', 'orca-cli')
await mkdir(skillDir, { recursive: true })
// Current bytes plus one upstream edit: content no snapshot in this build's
// registry has ever seen, exactly what `skills update` installs after the
// source repo moves past the release cut.
const current = await readFile(join(repoRoot, 'skills', 'orca-cli', 'SKILL.md'))
await writeFile(
join(skillDir, 'SKILL.md'),
Buffer.concat([current, Buffer.from('\nUpstream edit published after this build.\n')])
)
return { homeDir, installedTreeSha: (await observeSkillPackage(skillDir)).observedGitTreeSha }
}
async function writeLock(homeDir: string, skillFolderHash: string): Promise<void> {
await writeFile(
join(homeDir, '.agents', '.skill-lock.json'),
JSON.stringify({
version: 3,
skills: {
'orca-cli': {
skillFolderHash,
skillPath: 'skills/orca-cli',
source: 'github.com/stablyai/orca'
}
}
})
)
}
it('accepts post-cut source content when the lock records exactly those bytes', async () => {
const { homeDir, installedTreeSha } = await postCutFixture()
await writeLock(homeDir, installedTreeSha)
const inventory = await inventorySkillFreshness({
currentAppVersion: 'test',
homeDir,
resourceRoot: join(repoRoot, 'resources'),
repos: []
})
const locks = await readGloballyUpdatableSkillLocks({ homeDir })
// Guard the premise: no snapshot knows these bytes, so recognition can only
// come from the lock — the scan now reclassifies that match to 'newer-known'
// (the #11220 scan half), and the verdict accepts it either way.
const canonical = inventory.installations.filter(
(entry) => entry.name === 'orca-cli' && entry.topology === 'canonical-copy'
)
expect(canonical).toHaveLength(1)
expect(canonical[0].status).toBe('newer-known')
expect(canonical[0].installedReleaseRevision).toBeNull()
expect(skillUpdateFailedNames(['orca-cli'], inventory.installations, locks)).toEqual([])
})
it('keeps failing the same content when the lock names different bytes', async () => {
const { homeDir } = await postCutFixture()
await writeLock(homeDir, 'f'.repeat(40))
const inventory = await inventorySkillFreshness({
currentAppVersion: 'test',
homeDir,
resourceRoot: join(repoRoot, 'resources'),
repos: []
})
const locks = await readGloballyUpdatableSkillLocks({ homeDir })
expect(skillUpdateFailedNames(['orca-cli'], inventory.installations, locks)).toEqual([
'orca-cli'
])
})
})
+16 -41
View File
@@ -2,57 +2,32 @@ import {
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES,
type SkillFreshnessInstallation
} from '../../shared/skill-freshness'
import { matchesUpdaterLock } from './skill-update-registration'
/**
* Names that did not land, judged from the post-run inventory.
*
* Why not "whatever is still in `eligibleUpdateNames`": a name leaves that list
* for reasons that are not success. A half-written bundle hashes to
* `unrecognized`, a failed write can leave the directory unreadable, and a
* removed skill has no convergent placement at all — each would read as
* "updated" and show a green check over a broken skill. So demand a positive
* signal instead: every placement the command writes to has to come back as
* either a revision we recognise or the exact bytes the updater's lock says it
* installed.
* Why not "whatever is still in `eligibleUpdateNames`": a name leaves that list for
* reasons that are not success. A failed write can leave the directory unreadable, and
* a removed skill has no convergent placement at all; each would read as "updated" over
* a broken skill. The install is pinned to this build, so every placement it writes has
* to come back current.
*
* Known limit: the topology filter runs before the status check, so a placement
* that degrades OUT of the convergent set is dropped from the judgment rather
* than failing it. A run that rewrites the canonical copy but leaves a
* provider-alias a broken symlink still reports success. Catching that needs the
* pre-run inventory to compare convergent-then against convergent-now; every
* wholly-degraded and removed-entirely case is already reported as a failure.
* A broken link fails the name too: eligibility skips names that had one before the run,
* so one after it is the run's own damage.
*/
export function skillUpdateFailedNames(
names: readonly string[],
installations: readonly SkillFreshnessInstallation[],
globalSkillLocks: ReadonlyMap<string, string>
installations: readonly SkillFreshnessInstallation[]
): string[] {
return names.filter((name) => {
const lockHash = globalSkillLocks.get(name)
const convergent = installations.filter(
(entry) => entry.name === name && SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology)
const placements = installations.filter((entry) => entry.name === name)
const convergent = placements.filter((entry) =>
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology)
)
return (
convergent.length === 0 ||
convergent.some((entry) => entry.status !== 'current') ||
placements.some((entry) => entry.topology === 'broken-link')
)
if (convergent.length === 0) {
return true
}
return convergent.some((entry) => !skillPlacementLanded(entry, lockHash))
})
}
function skillPlacementLanded(
entry: SkillFreshnessInstallation,
lockHash: string | undefined
): boolean {
if (entry.status === 'current' || entry.status === 'newer-known') {
return true
}
// Why: the CLI installs source-repo HEAD, which runs ahead of the revisions this
// build bundles, so a clean update routinely hashes `unrecognized`. The lock is
// the CLI's own record of what it installed — disk matching lock means the
// command did its job and the bundled registry simply has not seen that revision
// yet. Everything else unrecognized (half-written, no lock entry, mismatch)
// still fails, and `outdated` is never forgiven: lock == disk there means the
// command provably wrote nothing.
return entry.status === 'unrecognized' && matchesUpdaterLock(entry, lockHash)
}
@@ -1,75 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readGloballyUpdatableSkillNames } from './skill-update-registration'
const temporaryDirectories: string[] = []
async function temporaryRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-registration-'))
temporaryDirectories.push(root)
return root
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('global skill update registration', () => {
it('reads only updateable entries from the external updater lock', async () => {
const homeDir = await temporaryRoot()
await mkdir(join(homeDir, '.agents'), { recursive: true })
await writeFile(
join(homeDir, '.agents', '.skill-lock.json'),
JSON.stringify({
version: 3,
skills: {
orchestration: {
skillFolderHash: 'hash',
skillPath: 'skills/orchestration/SKILL.md',
source: 'stablyai/orca'
},
copied: {},
emptyHash: {
skillFolderHash: '',
skillPath: 'skills/empty-hash/SKILL.md',
source: 'stablyai/orca'
},
emptyPath: {
skillFolderHash: 'hash',
skillPath: '',
source: 'stablyai/orca'
}
}
})
)
await expect(readGloballyUpdatableSkillNames({ homeDir, stateHome: null })).resolves.toEqual(
new Set(['orchestration'])
)
})
it('uses the XDG state lock when configured', async () => {
const root = await temporaryRoot()
const stateHome = join(root, 'state')
await mkdir(join(stateHome, 'skills'), { recursive: true })
await writeFile(
join(stateHome, 'skills', '.skill-lock.json'),
JSON.stringify({
version: 3,
skills: {
'orca-cli': {
skillFolderHash: 'hash',
skillPath: 'skills/orca-cli/SKILL.md',
source: 'stablyai/orca'
}
}
})
)
await expect(readGloballyUpdatableSkillNames({ homeDir: root, stateHome })).resolves.toEqual(
new Set(['orca-cli'])
)
})
})
@@ -1,110 +0,0 @@
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { SkillFreshnessInstallation } from '../../shared/skill-freshness'
const GLOBAL_SKILL_LOCK_SCHEMA_VERSION = 3
type SkillUpdateRegistrationArgs = {
homeDir?: string
stateHome?: string | null
}
function globalSkillLockPath(args: SkillUpdateRegistrationArgs): string {
const stateHome =
args.stateHome === undefined
? args.homeDir === undefined
? (process.env.XDG_STATE_HOME ?? null)
: null
: args.stateHome
return stateHome
? join(stateHome, 'skills', '.skill-lock.json')
: join(args.homeDir ?? homedir(), '.agents', '.skill-lock.json')
}
/**
* Whether a placement holds exactly the bytes the updater recorded installing.
*
* Either hash may carry it. The lock is the git tree sha of the source folder, so a
* folder an agent CLI dropped a sidecar into only matches once that sidecar is scoped
* out, while an upstream revision that added a file only matches whole — that file is
* in the lock's own tree but in no bundled snapshot. Requiring one specific hash trades
* either #12694 or #11220 for the other.
*
* Not exhaustive, and deliberately so: a folder carrying BOTH a sidecar and an upstream
* file no bundle lists matches neither hash and stays unrecognized, exactly as it does
* without this pair. Closing that needs the sidecar gone before hashing — an observe-time
* exclusion like `isOsMetadataSkillEntryName`, which means naming the foreign paths rather
* than tolerating any unlisted one. Editing a listed file still matches neither hash.
*/
export function matchesUpdaterLock(
installation: SkillFreshnessInstallation,
lockHash: string | undefined
): boolean {
return (
lockHash !== undefined &&
(installation.observedGitTreeSha === lockHash ||
installation.observedOfficialGitTreeSha === lockHash)
)
}
export async function readGloballyUpdatableSkillNames(
args: SkillUpdateRegistrationArgs = {}
): Promise<ReadonlySet<string>> {
return new Set((await readGloballyUpdatableSkillLocks(args)).keys())
}
/**
* Each updatable skill's recorded `skillFolderHash`, keyed by name.
*
* The hash is what the updater believes it installed. It is deliberately
* exposed alongside the names because `skills update` decides what to do by
* comparing this against the source and never reads disk — so when it disagrees
* with the bytes actually on disk, the command can only no-op.
*/
export async function readGloballyUpdatableSkillLocks(
args: SkillUpdateRegistrationArgs = {}
): Promise<ReadonlyMap<string, string>> {
try {
const parsed = JSON.parse(await readFile(globalSkillLockPath(args), 'utf8')) as {
version?: unknown
skills?: unknown
}
if (
typeof parsed.version !== 'number' ||
parsed.version < GLOBAL_SKILL_LOCK_SCHEMA_VERSION ||
!parsed.skills ||
typeof parsed.skills !== 'object' ||
Array.isArray(parsed.skills)
) {
return new Map()
}
return new Map(
Object.entries(parsed.skills)
.filter(([, value]) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const entry = value as {
skillFolderHash?: unknown
skillPath?: unknown
source?: unknown
}
return (
typeof entry.skillFolderHash === 'string' &&
entry.skillFolderHash.length > 0 &&
typeof entry.skillPath === 'string' &&
entry.skillPath.length > 0 &&
typeof entry.source === 'string' &&
entry.source.length > 0
)
})
.map(
([name, value]) => [name, (value as { skillFolderHash: string }).skillFolderHash] as const
)
)
} catch {
return new Map()
}
}
+15 -3
View File
@@ -6,6 +6,7 @@ import {
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { CANCEL_RELEASE_TIMEOUT_MS, SkillUpdateRunner } from './skill-update-run'
import { SKILLS_CLI_VERSION } from '../../shared/agent-feature-install-commands'
class FakeChild extends EventEmitter {
stdout = new EventEmitter()
@@ -26,6 +27,8 @@ function makeRunner(
const spawnCalls: { command: string; args: string[]; options: Record<string, unknown> }[] = []
const states: SkillUpdateRun[] = []
const runner = new SkillUpdateRunner({
sourceCommit: '0123456789abcdef0123456789abcdef01234567',
detectAgentKeys: () => ['claude-code', 'universal'],
now: () => 1000,
resolveCommand: overrides.resolveCommand ?? (() => '/usr/local/bin/npx'),
rescanOutdatedNames: overrides.rescanOutdatedNames,
@@ -46,7 +49,7 @@ async function flush(): Promise<void> {
}
describe('SkillUpdateRunner', () => {
it('passes both non-interactive flags and the sorted skill names', () => {
it('reinstalls the sorted names pinned to this release, for the detected agents', () => {
const { runner, spawnCalls } = makeRunner()
expect(runner.start(['orchestration', 'orca-cli'])).toEqual({ started: true })
@@ -55,11 +58,18 @@ describe('SkillUpdateRunner', () => {
// non-interactive branch. Dropping either can wedge the run.
expect(spawnCalls[0].args).toEqual([
'--yes',
'skills',
'update',
`skills@${SKILLS_CLI_VERSION}`,
'add',
'https://github.com/stablyai/orca/tree/0123456789abcdef0123456789abcdef01234567',
'--skill',
'orca-cli',
'--skill',
'orchestration',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
])
})
@@ -207,6 +217,8 @@ describe('SkillUpdateRunner', () => {
const children: FakeChild[] = []
const states: SkillUpdateRun[] = []
const runner = new SkillUpdateRunner({
sourceCommit: '0123456789abcdef0123456789abcdef01234567',
detectAgentKeys: () => ['universal'],
now: () => 1000,
resolveCommand: () => '/usr/local/bin/npx',
rescanOutdatedNames: async () => [],
+23 -12
View File
@@ -4,14 +4,16 @@ import {
type SkillUpdateRun,
type SkillUpdateStartResult
} from '../../shared/skill-freshness'
import { buildAgentFeatureSkillInstallArgs } from '../../shared/agent-feature-install-commands'
import { detectSkillsCliUpdateAgentKeys } from '../../shared/skills-cli-agent-detection'
import { resolveCliCommand } from '../codex-cli/command'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import { getSpawnArgsForWindows, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../win32-utils'
// Why: `skills update` prints ANSI colour and \r + erase-line progress. We show
// this log verbatim to the user but never parse it — `update` has no --json
// (that flag exists only on `list`), so stdout is not a contract.
// Why: `skills add` prints ANSI colour and \r + erase-line progress. We show
// this log verbatim to the user but never parse it — `add` has no --json, so
// stdout is not a contract.
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g // eslint-disable-line no-control-regex
// Keep the tail: failures land at the end, and an unbounded buffer would pin
@@ -28,6 +30,10 @@ const OUTPUT_FLUSH_MS = 100
export const CANCEL_RELEASE_TIMEOUT_MS = 12_000
export type SkillUpdateRunnerDeps = {
/** The commit the running build was made from; the install is pinned to it. */
sourceCommit: string
/** Same targets `orca skills update` picks on this host. */
detectAgentKeys?: () => string[]
spawnProcess?: typeof spawn
resolveCommand?: (commandName: string) => string
/** Returns the subset of `names` that did not land, re-read from disk. */
@@ -48,7 +54,9 @@ function clampOutput(value: string): string {
}
/**
* Runs `npx --yes skills update <names> --global -y` headlessly.
* Runs the pinned `npx --yes skills@<pinned> add <orca>/tree/<commit> --skill <name>... --global
* --agent ... -y` headlessly. `add` always re-copies, so the result is this build's bytes
* however the old copy was installed or edited.
*
* Both `--yes` flags are load-bearing and distinct: `npx --yes` skips the
* install-this-package prompt, and `skills … -y` takes the CLI's own
@@ -68,7 +76,7 @@ export class SkillUpdateRunner {
private killing = false
private readonly deps: Required<Pick<SkillUpdateRunnerDeps, 'now'>> & SkillUpdateRunnerDeps
constructor(deps: SkillUpdateRunnerDeps = {}) {
constructor(deps: SkillUpdateRunnerDeps) {
this.deps = { now: () => Date.now(), ...deps }
}
@@ -93,7 +101,14 @@ export class SkillUpdateRunner {
const resolveCommand = this.deps.resolveCommand ?? ((name: string) => resolveCliCommand(name))
const spawnProcess = this.deps.spawnProcess ?? spawn
const npxCommand = resolveCommand('npx')
const npxArgs = ['--yes', 'skills', 'update', ...canonicalNames, '--global', '-y']
const npxArgs = [
'--yes',
...buildAgentFeatureSkillInstallArgs(canonicalNames, {
sourceCommit: this.deps.sourceCommit,
yes: true,
agents: (this.deps.detectAgentKeys ?? detectSkillsCliUpdateAgentKeys)()
})
]
let spawnCmd: string
let spawnArgs: string[]
@@ -176,11 +191,7 @@ export class SkillUpdateRunner {
})
child.on('close', (code) => {
drain()
this.settle(
token,
canonicalNames,
code === 0 ? null : `skills update exited with code ${code}`
)
this.settle(token, canonicalNames, code === 0 ? null : `skills add exited with code ${code}`)
})
return { started: true }
@@ -199,7 +210,7 @@ export class SkillUpdateRunner {
// Why: when the re-scan produces a verdict it *is* the answer — it re-hashes
// what landed on disk, which is what the user actually cares about. The exit
// code only decides the outcome when no verdict is available, because
// `skills update` reports nothing else we can trust.
// `skills add` reports nothing else we can trust.
const finish = (failedNames: string[] | null): void => {
// The re-scan is slow enough that a cancel — or a whole replacement run —
// can land while it is still in flight; its verdict is about a run that no
@@ -1,8 +1,7 @@
import type { JSX } from 'react'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND
ORCA_CLI_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state'
import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills'
@@ -22,9 +21,6 @@ export function BrowserUseSkillSetupCard(props: {
const installCommand = !activeSkillRuntime.installDisabledReason
? buildSkillCommandForRuntime(ORCA_CLI_SKILL_INSTALL_COMMAND, activeSkillRuntime.agentRuntime)
: ORCA_CLI_SKILL_INSTALL_COMMAND
const updateCommand = !activeSkillRuntime.installDisabledReason
? buildSkillCommandForRuntime(ORCA_CLI_SKILL_UPDATE_COMMAND, activeSkillRuntime.agentRuntime)
: ORCA_CLI_SKILL_UPDATE_COMMAND
const handleBeforeOpenTerminal = (): void => {
useAppStore.getState().recordFeatureInteraction('agent-browser-setup')
@@ -43,7 +39,6 @@ export function BrowserUseSkillSetupCard(props: {
"Enables agents to navigate and verify pages in Orca's browser."
)}
command={installCommand}
installedCommand={updateCommand}
terminalTitle="Browser Use setup"
terminalAriaLabel="Browser Use skill install terminal"
terminalWorktreeId="feature-wall-browser-use-skill-terminal"
@@ -53,7 +53,29 @@ vi.mock('@/store', () => ({
}))
function inventory(eligibleUpdateNames: string[]): SkillFreshnessInventory {
return { schemaVersion: 1, installations: [], eligibleUpdateNames, scanIssues: [], scannedAt: 1 }
return {
schemaVersion: 1,
// Why: an update is only advertised for a copy the skills.sh lock attributes to Orca.
installations: eligibleUpdateNames.map((name) => ({
id: name,
name,
rootId: 'home-agents',
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
unresolvedPath: `/home/.agents/skills/${name}`,
resolvedPath: `/home/.agents/skills/${name}`,
physicalIdentity: `physical-${name}`,
topology: 'canonical-copy',
status: 'outdated',
currentPackageDigest: 'current',
lockSource: 'stablyai/orca',
errorCategory: null
})),
eligibleUpdateNames,
scanIssues: [],
scannedAt: 1
}
}
let root: Root | null = null
@@ -9,11 +9,10 @@ import {
import { AgentSkillSetupPanel } from '@/components/settings/AgentSkillSetupPanel'
import { IntegrationStatusPill } from '@/components/integration-status-pill'
import { SkillFreshnessStatusPill } from '@/components/skills/SkillFreshnessStatusPill'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import {
ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCHESTRATION_SKILL_UPDATE_COMMAND
} from '@/lib/orchestration-install-command'
ORCHESTRATION_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkill
@@ -42,12 +41,6 @@ export function FloatingTerminalOrchestrationDialog({
activeSkillRuntime.agentRuntime
)
: ORCHESTRATION_SKILL_INSTALL_COMMAND
const updateCommand = !activeSkillRuntime.installDisabledReason
? buildSkillCommandForRuntime(
ORCHESTRATION_SKILL_UPDATE_COMMAND,
activeSkillRuntime.agentRuntime
)
: ORCHESTRATION_SKILL_UPDATE_COMMAND
const {
installed: orchestrationSkillDetected,
loading: orchestrationSkillLoading,
@@ -133,7 +126,6 @@ export function FloatingTerminalOrchestrationDialog({
'Enables agents to hand off context and coordinate work through Orca.'
)}
command={installCommand}
installedCommand={updateCommand}
terminalTitle="Orchestration setup"
terminalAriaLabel="Orchestration skill install terminal"
terminalWorktreeId="floating-terminal-orchestration-skill-terminal"
@@ -26,6 +26,7 @@ import {
type OnboardingFeatureSetupSelection
} from './onboarding-feature-setup'
import { getOnboardingFeatureSetupAgentRuntime } from './onboarding-feature-setup-runtime'
import { SKILLS_CLI_VERSION } from '../../../../shared/agent-feature-install-commands'
const ALL_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_CLI_SKILL_NAME,
@@ -110,7 +111,7 @@ describe('onboarding feature setup runner', () => {
expect(text).toBe(ALL_SKILL_INSTALL_COMMAND)
expect(text).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill computer-use --skill orchestration --skill orca-linear --global'
`npx skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${ORCA_BUILD_COMMIT} --skill orca-cli --skill computer-use --skill orchestration --skill orca-linear --global`
)
})
@@ -22,7 +22,29 @@ vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({
}))
function inventory(eligibleUpdateNames: string[]): SkillFreshnessInventory {
return { schemaVersion: 1, installations: [], eligibleUpdateNames, scanIssues: [], scannedAt: 1 }
return {
schemaVersion: 1,
// Why: an update is only advertised for a copy the skills.sh lock attributes to Orca.
installations: eligibleUpdateNames.map((name) => ({
id: name,
name,
rootId: 'home-agents',
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
unresolvedPath: `/home/.agents/skills/${name}`,
resolvedPath: `/home/.agents/skills/${name}`,
physicalIdentity: `physical-${name}`,
topology: 'canonical-copy',
status: 'outdated',
currentPackageDigest: 'current',
lockSource: 'stablyai/orca',
errorCategory: null
})),
eligibleUpdateNames,
scanIssues: [],
scannedAt: 1
}
}
function panelProps(
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND
ORCA_CLI_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state'
import {
@@ -42,9 +41,6 @@ export function BrowserUseSetup({
const browserUseInstallCommand = !activeSkillRuntime.installDisabledReason
? buildSkillCommandForRuntime(ORCA_CLI_SKILL_INSTALL_COMMAND, activeSkillRuntime.agentRuntime)
: ORCA_CLI_SKILL_INSTALL_COMMAND
const browserUseUpdateCommand = !activeSkillRuntime.installDisabledReason
? buildSkillCommandForRuntime(ORCA_CLI_SKILL_UPDATE_COMMAND, activeSkillRuntime.agentRuntime)
: ORCA_CLI_SKILL_UPDATE_COMMAND
const [browserUseEnabled, setBrowserUseEnabled] = useState<boolean>(() => {
return localStorage.getItem(BROWSER_USE_ENABLED_STORAGE_KEY) === '1'
@@ -171,7 +167,6 @@ export function BrowserUseSetup({
>
<BrowserUseSkillStep
command={browserUseInstallCommand}
installedCommand={browserUseUpdateCommand}
skillDetected={skillDetected}
skillLoading={skillLoading}
skillError={activeSkillRuntime.installDisabledReason ?? skillError}
@@ -1,5 +1,6 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import { BrowserUseSkillStep } from './BrowserUseSkillStep'
const capturedPanel = vi.hoisted(() => ({
@@ -14,15 +15,10 @@ vi.mock('./AgentSkillSetupPanel', () => ({
}))
describe('BrowserUseSkillStep', () => {
it('forwards a single-skill installed command even when setup installs a bundle', () => {
const bundleInstallCommand =
'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global'
const updateCommand = 'npx skills update orca-cli --global'
it('reuses the pinned install command as the update once installed', () => {
renderToStaticMarkup(
<BrowserUseSkillStep
command={bundleInstallCommand}
installedCommand={updateCommand}
command={ORCA_CLI_SKILL_INSTALL_COMMAND}
skillDetected
skillLoading={false}
skillError={null}
@@ -31,11 +27,8 @@ describe('BrowserUseSkillStep', () => {
)
expect(capturedPanel.props).toEqual(
expect.objectContaining({
command: bundleInstallCommand,
installedCommand: updateCommand,
installed: true
})
expect.objectContaining({ command: ORCA_CLI_SKILL_INSTALL_COMMAND, installed: true })
)
expect(capturedPanel.props?.installedCommand).toBeUndefined()
})
})
@@ -5,7 +5,6 @@ import { translate } from '@/i18n/i18n'
type Props = {
command: string
installedCommand: string
skillDetected: boolean
skillLoading: boolean
skillError: string | null
@@ -18,7 +17,6 @@ type Props = {
export function BrowserUseSkillStep({
command,
installedCommand,
skillDetected,
skillLoading,
skillError,
@@ -40,7 +38,6 @@ export function BrowserUseSkillStep({
"Enables agents to navigate and verify pages in Orca's browser."
)}
command={command}
installedCommand={installedCommand}
terminalTitle="Browser Use setup"
terminalAriaLabel="Browser Use skill install terminal"
terminalWorktreeId="settings-browser-use-skill-terminal"
@@ -4,10 +4,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_UPDATE_COMMAND
} from '@/lib/agent-feature-install-commands'
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import { CliSection } from './CliSection'
type CapturedPanelProps = {
@@ -140,7 +137,8 @@ describe('CliSection project runtime defaults', () => {
})
)
expect(capturedPanel.props?.command).toBe(ORCA_CLI_SKILL_INSTALL_COMMAND)
expect(capturedPanel.props?.installedCommand).toBe(ORCA_CLI_SKILL_UPDATE_COMMAND)
// `skills add` re-copies, so the panel reuses the install command as its update.
expect(capturedPanel.props?.installedCommand).toBeUndefined()
expect(capturedPanel.props?.terminalRuntime).toEqual({
runtime: 'wsl',
wslDistro: 'Ubuntu',
@@ -6,8 +6,7 @@ import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND
ORCA_CLI_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
@@ -100,10 +99,6 @@ export function CliSection({
ORCA_CLI_SKILL_INSTALL_COMMAND,
agentRuntime
)
const cliSkillUpdateCommand = buildSkillCommandForRuntime(
ORCA_CLI_SKILL_UPDATE_COMMAND,
agentRuntime
)
const cliSkillTerminalShellOverride = getAgentSkillTerminalShellOverride(
currentPlatform,
settings,
@@ -331,7 +326,6 @@ export function CliSection({
'Enables agents to use Orca workspace, terminal, and progress commands.'
)}
command={cliSkillInstallCommand}
installedCommand={cliSkillUpdateCommand}
terminalTitle="CLI skill setup"
terminalAriaLabel="CLI skill install terminal"
terminalWorktreeId={`settings-cli-skill-terminal-${agentRuntime.runtime}`}
@@ -4,9 +4,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { buildAgentFeatureSkillInstallCommand } from '../../../../shared/agent-feature-install-commands'
import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command'
import { useAppStore } from '@/store'
import { buildAgentFeatureSkillInstallCommand } from '@/lib/agent-feature-install-commands'
import {
buildSkillCommandForRuntime,
buildSkillInstallCommandForRuntime,
@@ -178,29 +178,6 @@ describe('CliSkillRuntimeSetup runtime helpers', () => {
)
})
it('reinstalls Windows-host skill updates after the npx preflight', () => {
const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration'])
expect(
buildSkillCommandForRuntime(
'npx skills update orchestration --global',
{
runtime: 'host',
label: 'Windows'
},
'win32'
)
).toBe(`${windowsNpxPreflightPrefix}${windowsNpxGuidance}) else (${installCommand})"`)
})
it('treats missing runtime as a preflighted Windows host fallback for skill updates', () => {
const installCommand = buildAgentFeatureSkillInstallCommand(['orca-cli'])
expect(
buildSkillCommandForRuntime('npx skills update orca-cli --global', undefined, 'win32')
).toBe(`${windowsNpxPreflightPrefix}${windowsNpxGuidance}) else (${installCommand})"`)
})
it('keeps non-Windows host skill installs on the direct npx path', () => {
const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration'])
@@ -216,19 +193,6 @@ describe('CliSkillRuntimeSetup runtime helpers', () => {
).toBe(installCommand)
})
it('keeps non-Windows host skill updates on the update path', () => {
expect(
buildSkillCommandForRuntime(
'npx skills update orchestration --global',
{
runtime: 'host',
label: 'This device'
},
'linux'
)
).toBe('npx skills update orchestration --global')
})
it('skips the Windows preflight while a remote runtime environment is focused', () => {
const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration'])
const windowsHost = { runtime: 'host', label: 'Windows' } as const
@@ -369,26 +333,6 @@ describe('CliSkillRuntimeSetup runtime helpers', () => {
)
})
it('keeps the bare reinstall rewrite for POSIX-family Windows skill updates', () => {
const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration'])
const previous = useAppStore.getState()
useAppStore.setState({
settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'git-bash' }
})
try {
expect(
buildSkillCommandForRuntime(
'npx skills update orchestration --global',
{ runtime: 'host', label: 'Windows' },
'win32'
)
).toBe(installCommand)
} finally {
useAppStore.setState({ settings: previous.settings })
}
})
it('does not wrap unrelated Windows host commands', () => {
expect(
buildSkillCommandForRuntime(
@@ -12,7 +12,6 @@ import { isWslShellName } from '../../../../shared/local-windows-terminal-runtim
import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell'
import { getProjectAgentSkillTerminalShellOverride } from '@/lib/project-skill-runtime'
import { useAppStore } from '@/store'
import { buildAgentFeatureSkillInstallCommand } from '../../../../shared/agent-feature-install-commands'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import {
@@ -84,40 +83,10 @@ export function buildSkillCommandForRuntime(
currentPlatform = getSkillCommandPlatform()
): string {
const resolvedRuntime = runtime ?? LOCAL_HOST_AGENT_RUNTIME
const normalizedCommand = normalizeWindowsSkillUpdateCommand(
command,
resolvedRuntime,
currentPlatform
)
if (resolvedRuntime.runtime !== 'wsl') {
return wrapWindowsSkillCommandWithNpxPrerequisite(
normalizedCommand,
currentPlatform,
'copied-command'
)
return wrapWindowsSkillCommandWithNpxPrerequisite(command, currentPlatform, 'copied-command')
}
return normalizedCommand
}
function normalizeWindowsSkillUpdateCommand(
command: string,
runtime: LocalAgentRuntime,
currentPlatform: NodeJS.Platform
): string {
if (runtime.runtime === 'wsl' || currentPlatform !== 'win32') {
return command
}
const trimmedCommand = command.trim()
const updateMatch = /^npx\s+skills\s+update\s+([A-Za-z0-9_-]+)\s+--global$/i.exec(trimmedCommand)
if (!updateMatch) {
return command
}
// Why: the `skills update` subcommand is currently unreliable on native
// Windows, while reinstalling from the same repo source is idempotent and
// keeps the setup affordance working.
return buildAgentFeatureSkillInstallCommand([updateMatch[1]])
return command
}
/**
@@ -227,7 +196,7 @@ function wrapWindowsSkillCommandWithNpxPrerequisite(
// shells rewrite cmd.exe's leading /d /s /c switches into drive paths,
// starting an interactive cmd session instead of running the payload.
(target === 'copied-command' && isPosixFamilyWindowsShellConfigured()) ||
!/^npx\s+skills\s+(?:add|update)\b/i.test(trimmedCommand)
!/^npx\s+skills(?:@[\w.-]+)?\s+add\b/i.test(trimmedCommand)
) {
return command
}
@@ -1,8 +1,7 @@
import { MonitorCog } from 'lucide-react'
import {
COMPUTER_USE_SKILL_INSTALL_COMMAND,
COMPUTER_USE_SKILL_NAME,
COMPUTER_USE_SKILL_UPDATE_COMMAND
COMPUTER_USE_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
@@ -22,12 +21,6 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
activeSkillRuntime.agentRuntime
)
: COMPUTER_USE_SKILL_INSTALL_COMMAND
const updateCommand = !activeSkillRuntime.installDisabledReason
? buildSkillCommandForRuntime(
COMPUTER_USE_SKILL_UPDATE_COMMAND,
activeSkillRuntime.agentRuntime
)
: COMPUTER_USE_SKILL_UPDATE_COMMAND
const {
installed: computerUseSkillDetected,
loading: computerUseSkillLoading,
@@ -46,7 +39,6 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
'Enables agents to inspect and operate local desktop apps.'
)}
command={installCommand}
installedCommand={updateCommand}
terminalTitle="Computer Use setup"
terminalAriaLabel="Computer Use skill install terminal"
terminalWorktreeId="settings-computer-use-skill-terminal"
@@ -10,8 +10,7 @@ import { EphemeralVmRecipeRow } from './EphemeralVmRecipeRow'
import { translate } from '@/i18n/i18n'
import {
EPHEMERAL_VMS_SKILL_INSTALL_COMMAND,
EPHEMERAL_VMS_SKILL_NAME,
EPHEMERAL_VMS_SKILL_UPDATE_COMMAND
EPHEMERAL_VMS_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
@@ -55,12 +54,6 @@ export function EphemeralVmsPane(): React.JSX.Element {
EPHEMERAL_VMS_SKILL_INSTALL_COMMAND,
activeSkillRuntime.agentRuntime
)
const updateCommand = activeSkillRuntime.installDisabledReason
? EPHEMERAL_VMS_SKILL_UPDATE_COMMAND
: buildSkillCommandForRuntime(
EPHEMERAL_VMS_SKILL_UPDATE_COMMAND,
activeSkillRuntime.agentRuntime
)
const {
installed: skillDetected,
@@ -162,7 +155,6 @@ export function EphemeralVmsPane(): React.JSX.Element {
'Sets up, builds, authenticates, and validates repo-owned environment recipes.'
)}
command={installCommand}
installedCommand={updateCommand}
terminalTitle={translate(
'auto.components.settings.EphemeralVmsPane.cloudVmTerminalTitle',
'Cloud VM setup'
@@ -11,7 +11,7 @@ import {
import { getLinearUsageExamples } from '@/lib/linear-usage-examples'
import { LinearAgentSkillPane } from './LinearAgentSkillPane'
const UPDATE_COMMAND = 'npx skills update orca-linear --global'
const UPDATE_COMMAND = ORCA_LINEAR_SKILL_INSTALL_COMMAND
const mocks = vi.hoisted(() => ({
panelProps: [] as Record<string, unknown>[],
@@ -1,8 +1,7 @@
import { Import } from 'lucide-react'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND
ORCA_CLI_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { useMobileEmulatorAgentSetupState } from '../emulator-pane/use-mobile-emulator-agent-setup-state'
@@ -24,7 +23,6 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
// Why: skill detection here scans the local host only, so keep building host
// commands; routing them to a WSL runtime would install where we never look.
const cliSkillInstallCommand = buildSkillCommandForRuntime(ORCA_CLI_SKILL_INSTALL_COMMAND)
const cliSkillUpdateCommand = buildSkillCommandForRuntime(ORCA_CLI_SKILL_UPDATE_COMMAND)
return (
<div className="rounded-2xl border border-border/60 bg-card/30 p-4">
@@ -58,7 +56,6 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
'Enables agents to use Orca CLI commands, including mobile emulator control.'
)}
command={cliSkillInstallCommand}
installedCommand={cliSkillUpdateCommand}
terminalTitle="Orca CLI skill setup"
terminalAriaLabel="Orca CLI skill install terminal"
terminalWorktreeId="settings-mobile-emulator-orca-cli-skill-terminal"
@@ -11,12 +11,10 @@ import { useAppStore } from '../../store'
import { OrchestrationPane } from './OrchestrationPane'
import { getOrchestrationPaneSearchEntries } from './orchestration-search'
import { matchesSettingsSearch } from './settings-search'
import { SKILLS_CLI_VERSION } from '../../../../shared/agent-feature-install-commands'
const INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
const UPDATE_COMMAND = INSTALL_COMMAND
const WINDOWS_INSTALL_COMMAND =
'cmd.exe /d /s /c "where.exe npx >nul 2>nul & if errorlevel 1 (echo ERROR: npx was not found. Install Node.js LTS from https://nodejs.org/ to get npx. & echo Then close this terminal and start skill setup again - a new terminal picks up the updated PATH. & exit /b 1) else (npx skills add https://github.com/stablyai/orca --skill orchestration --global)"'
const INSTALL_COMMAND = `npx skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${ORCA_BUILD_COMMIT} --skill orchestration --global`
const WINDOWS_INSTALL_COMMAND = `cmd.exe /d /s /c "where.exe npx >nul 2>nul & if errorlevel 1 (echo ERROR: npx was not found. Install Node.js LTS from https://nodejs.org/ to get npx. & echo Then close this terminal and start skill setup again - a new terminal picks up the updated PATH. & exit /b 1) else (npx skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${ORCA_BUILD_COMMIT} --skill orchestration --global)"`
const mocks = vi.hoisted(() => ({
dialogProps: [] as Record<string, unknown>[],
@@ -255,15 +253,13 @@ describe('OrchestrationPane', () => {
).toBe(false)
})
it('passes update commands to the main panel without an installed manual-copy path', async () => {
it('passes the install command, which also updates, without a manual-copy path', async () => {
const rendered = await renderPane()
expect(mocks.panelProps.at(-1)).toEqual(
expect.objectContaining({
command: WINDOWS_INSTALL_COMMAND,
installedCommand: WINDOWS_INSTALL_COMMAND
})
expect.objectContaining({ command: WINDOWS_INSTALL_COMMAND })
)
expect(mocks.panelProps.at(-1)?.installedCommand).toBeUndefined()
expect(rendered.textContent).not.toContain('Prefer your own terminal?')
expect(rendered.textContent).not.toContain('Copy update command')
@@ -271,7 +267,7 @@ describe('OrchestrationPane', () => {
expect(mocks.dialogProps).not.toContainEqual(expect.objectContaining({ mode: 'update' }))
expect(mocks.dialogProps).not.toContainEqual(
expect.objectContaining({
command: UPDATE_COMMAND,
command: INSTALL_COMMAND,
open: true
})
)
@@ -1,11 +1,10 @@
import { useState } from 'react'
import { ArrowRightLeft, GitBranch, ListChecks, Workflow, type LucideIcon } from 'lucide-react'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import type { SkillUsageExample } from '@/lib/skill-usage-example'
import {
ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCHESTRATION_SKILL_UPDATE_COMMAND
} from '@/lib/orchestration-install-command'
ORCHESTRATION_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import type { SkillUsageExample } from '@/lib/skill-usage-example'
import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-examples'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
@@ -66,12 +65,6 @@ export function OrchestrationPane({
activeSkillRuntime.agentRuntime
)
: ORCHESTRATION_SKILL_INSTALL_COMMAND
const orchestrationUpdateCommand = !activeSkillRuntime.installDisabledReason
? buildSkillCommandForRuntime(
ORCHESTRATION_SKILL_UPDATE_COMMAND,
activeSkillRuntime.agentRuntime
)
: ORCHESTRATION_SKILL_UPDATE_COMMAND
const {
installed: orchestrationSkillDetected,
@@ -113,7 +106,6 @@ export function OrchestrationPane({
'Enables agents to hand off context and coordinate work through Orca.'
)}
command={orchestrationInstallCommand}
installedCommand={orchestrationUpdateCommand}
terminalTitle="Orchestration setup"
terminalAriaLabel="Orchestration skill install terminal"
terminalWorktreeId="settings-orchestration-skill-terminal"
@@ -1,9 +1,8 @@
import type { JSX } from 'react'
import {
ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCHESTRATION_SKILL_UPDATE_COMMAND
} from '@/lib/orchestration-install-command'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
ORCHESTRATION_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
@@ -24,12 +23,6 @@ export function OrchestrationSetupCard(props: {
activeSkillRuntime.agentRuntime
)
: ORCHESTRATION_SKILL_INSTALL_COMMAND
const updateCommand = !activeSkillRuntime.installDisabledReason
? buildSkillCommandForRuntime(
ORCHESTRATION_SKILL_UPDATE_COMMAND,
activeSkillRuntime.agentRuntime
)
: ORCHESTRATION_SKILL_UPDATE_COMMAND
const setupPanel = (
<AgentSkillSetupPanel
@@ -43,7 +36,6 @@ export function OrchestrationSetupCard(props: {
'Enables agents to hand off context and coordinate work through Orca.'
)}
command={installCommand}
installedCommand={updateCommand}
terminalTitle="Orchestration setup"
terminalAriaLabel="Orchestration skill install terminal"
terminalWorktreeId="feature-wall-orchestration-skill-terminal"
@@ -6,6 +6,7 @@ import type { DiscoveredSkill } from '../../../../shared/skills'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '../ui/tooltip'
import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta'
import { SKILLS_CLI_VERSION } from '../../../../shared/agent-feature-install-commands'
const mocks = vi.hoisted(() => ({
skillState: {
@@ -116,7 +117,7 @@ describe('LinearAgentSkillInstallCta', () => {
'Full guided setup (connect + skill + visibility) is under Settings → Task Sources.'
)
expect(rendered.textContent).toContain(
'npx skills add https://github.com/stablyai/orca --skill orca-linear --global'
`npx skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${ORCA_BUILD_COMMIT} --skill orca-linear --global`
)
})
@@ -130,7 +131,7 @@ describe('LinearAgentSkillInstallCta', () => {
})
expect(mocks.clipboardWrite).toHaveBeenCalledWith(
'npx skills add https://github.com/stablyai/orca --skill orca-linear --global'
`npx skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${ORCA_BUILD_COMMIT} --skill orca-linear --global`
)
expect(mocks.toastSuccess).toHaveBeenCalled()
})
@@ -143,7 +144,9 @@ describe('LinearAgentSkillInstallCta', () => {
expect(rendered.textContent).toContain('Installed')
expect(rendered.textContent).toContain('Agent skill installed. To update it, run:')
expect(rendered.textContent).toContain('npx skills update orca-linear --global')
expect(rendered.textContent).toContain(
`npx skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${ORCA_BUILD_COMMIT} --skill orca-linear --global`
)
expect(rendered.textContent).not.toContain('Not installed')
})
@@ -159,7 +162,9 @@ describe('LinearAgentSkillInstallCta', () => {
const rendered = await renderCta()
expect(rendered.textContent).toContain('npx skills update linear-tickets --global')
expect(rendered.textContent).toContain(
`npx skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${ORCA_BUILD_COMMIT} --skill linear-tickets --global`
)
})
it('notes that remote agent environments need their own setup', async () => {
@@ -10,6 +10,7 @@ import {
_linearAgentSkillSetupPromptInternalsForTests
} from './LinearAgentSkillSetupPrompt'
import type { LinearAgentSkillPromptSettings } from './linear-agent-skill-runtime'
import { SKILLS_CLI_VERSION } from '../../../../shared/agent-feature-install-commands'
const HOST_DISMISS_STORAGE_KEY = 'orca.linearTicketsSkill.setupDismissed.host'
const FEDORA_DISMISS_STORAGE_KEY = 'orca.linearTicketsSkill.setupDismissed.wsl.Fedora'
@@ -349,10 +350,10 @@ describe('LinearAgentSkillSetupPrompt', () => {
})
await settleRender()
expect(document.body.textContent).toContain('npx skills add')
expect(document.body.textContent).toContain(`npx skills@${SKILLS_CLI_VERSION} add`)
expect(mocks.panelProps.at(-1)).toEqual(
expect.objectContaining({
installedCommand: 'npx skills update orca-linear --global',
installedCommand: `npx skills@${SKILLS_CLI_VERSION} add https://github.com/stablyai/orca/tree/${ORCA_BUILD_COMMIT} --skill orca-linear --global`,
terminalShellOverride: 'powershell.exe',
terminalRuntime: expect.objectContaining({ runtime: 'wsl', wslDistro: 'Fedora' })
})
@@ -717,7 +718,7 @@ describe('LinearAgentSkillSetupPrompt', () => {
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await settleRender()
expect(mocks.panelProps.at(-1)?.command).toContain('npx skills add')
expect(mocks.panelProps.at(-1)?.command).toContain(`npx skills@${SKILLS_CLI_VERSION} add`)
expect(mocks.panelProps.at(-1)?.terminalRuntime).toEqual(
expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu' })
)
@@ -37,12 +37,8 @@ function placement(
physicalIdentity: 'physical-orca-cli',
topology: 'canonical-copy',
status: 'outdated',
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
lockSource: 'stablyai/orca',
errorCategory: null,
...overrides
}
@@ -112,7 +108,7 @@ async function rerenderNudge(): Promise<void> {
})
}
const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', '2'].join('\0')
const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', 'current'].join('\0')
describe('SkillFreshnessNudge', () => {
beforeEach(() => {
@@ -149,6 +145,45 @@ describe('SkillFreshnessNudge', () => {
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('counts only Orca-owned outdated skills', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement(),
placement({
id: 'computer-use',
name: 'computer-use',
physicalIdentity: 'physical-computer-use',
lockSource: 'acme/agent-skills'
}),
placement({
id: 'orchestration',
name: 'orchestration',
physicalIdentity: 'physical-orchestration',
lockSource: null
})
],
eligibleUpdateNames: ['computer-use', 'orca-cli', 'orchestration'],
scanIssues: [],
scannedAt: 1
}
await renderNudge()
expect(mocks.toastInfo).toHaveBeenCalledTimes(1)
expect(mocks.toastInfo.mock.calls[0]?.[0]).toBe('An installed Orca skill is out of date')
expect(mocks.toastInfo.mock.calls[0]?.[1].description).toContain('Update orca-cli so')
})
it('raises no nudge when every outdated copy came from somewhere else', async () => {
mocks.inventory = {
...eligibleInventory(),
installations: [placement({ lockSource: 'acme/agent-skills' })]
}
await renderNudge()
expect(mocks.toastInfo).not.toHaveBeenCalled()
})
it('opens the update dialog on action click without persisting a dismissal', async () => {
await renderNudge()
@@ -166,7 +201,7 @@ describe('SkillFreshnessNudge', () => {
mocks.inventory = {
schemaVersion: 1,
installations: [placement({ status: 'current', observedPackageDigest: 'current' })],
installations: [placement({ status: 'current' })],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 2
@@ -5,7 +5,10 @@ import { useSkillFreshness } from '@/hooks/useSkillFreshness'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { skillPlacementParticipatesInGlobalFreshness } from '../../../../shared/skill-freshness'
import {
isOrcaSkillLockSource,
skillPlacementParticipatesInGlobalFreshness
} from '../../../../shared/skill-freshness'
import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog'
const MAX_DISMISSED_FRESHNESS_NUDGES = 512
@@ -20,9 +23,9 @@ type ActiveFreshnessNudge = {
function candidateKey(args: {
physicalIdentity: string
name: string
currentReleaseRevision: number
currentPackageDigest: string
}): string {
return [args.physicalIdentity, args.name, args.currentReleaseRevision].join('\0')
return [args.physicalIdentity, args.name, args.currentPackageDigest].join('\0')
}
export function SkillFreshnessNudge(): null {
@@ -70,13 +73,15 @@ export function SkillFreshnessNudge(): null {
skillPlacementParticipatesInGlobalFreshness(installation) &&
installation.status === 'outdated' &&
eligibleNames.has(installation.name) &&
// Why: a same-named skill from somewhere else is listed in the dialog, never nudged.
isOrcaSkillLockSource(installation.lockSource) &&
installation.physicalIdentity
? [
{
key: candidateKey({
physicalIdentity: installation.physicalIdentity,
name: installation.name,
currentReleaseRevision: installation.currentReleaseRevision
currentPackageDigest: installation.currentPackageDigest
}),
name: installation.name
}
@@ -31,7 +31,7 @@ function pillText(container: HTMLDivElement): string {
}
function inventory(
entries: { name: string; status: 'current' | 'outdated' | 'unrecognized' }[],
entries: { name: string; status: 'current' | 'outdated' | 'inaccessible' }[],
eligibleUpdateNames: string[]
): SkillFreshnessInventory {
return {
@@ -48,12 +48,8 @@ function inventory(
physicalIdentity: `physical-${entry.name}-${index}`,
topology: 'canonical-copy',
status: entry.status,
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
lockSource: 'stablyai/orca',
errorCategory: null
})),
eligibleUpdateNames,
@@ -114,7 +110,7 @@ describe('SkillFreshnessStatusPill', () => {
mocks.inventory = inventory(
[
{ name: 'orca-cli', status: 'outdated' },
{ name: 'orca-cli', status: 'unrecognized' }
{ name: 'orca-cli', status: 'inaccessible' }
],
[]
)
@@ -108,12 +108,8 @@ function placement(
physicalIdentity: `physical-${name}`,
topology: 'canonical-copy',
status: 'outdated',
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
lockSource: 'stablyai/orca',
errorCategory: null,
...overrides
}
@@ -281,7 +277,7 @@ describe('SkillFreshnessUpdateDialog', () => {
failedNames: ['orchestration'],
finishedAt: 3,
output: '✗ Failed to update orchestration',
message: 'skills update exited with code 1'
message: 'skills add exited with code 1'
})
expect(container?.textContent).toContain('Updated 1 of 2 skills')
@@ -291,7 +287,7 @@ describe('SkillFreshnessUpdateDialog', () => {
expect(
container?.querySelector('[data-skill-row="orchestration"]')?.getAttribute('data-state-label')
).toBe('failed')
expect(container?.textContent).toContain('skills update exited with code 1')
expect(container?.textContent).toContain('skills add exited with code 1')
expect(findButton('Retry')).toBeDefined()
})
@@ -310,7 +306,7 @@ describe('SkillFreshnessUpdateDialog', () => {
// the group list and blank the row out mid-transition.
mocks.inventory = {
schemaVersion: 1,
installations: [placement('orca-cli', { status: 'current', installedReleaseRevision: 2 })],
installations: [placement('orca-cli', { status: 'current' })],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 5
@@ -419,7 +415,7 @@ describe('SkillFreshnessUpdateDialog', () => {
it('shows the up-to-date state once every installation is current', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [placement('orca-cli', { status: 'current', installedReleaseRevision: 2 })],
installations: [placement('orca-cli', { status: 'current' })],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 2
@@ -471,7 +467,7 @@ describe('SkillFreshnessUpdateDialog', () => {
rootId: 'repo-work',
sourceKind: 'repo',
topology: 'repo-scope',
status: 'unrecognized',
status: 'outdated',
unresolvedPath: '/home/projects/work/.agents/skills/computer-use',
resolvedPath: '/home/projects/work/.agents/skills/computer-use',
physicalIdentity: 'physical-repo-computer-use'
@@ -500,7 +496,7 @@ describe('SkillFreshnessUpdateDialog', () => {
rootId: 'repo-work',
sourceKind: 'repo',
topology: 'repo-scope',
status: 'unrecognized',
status: 'outdated',
unresolvedPath: '/home/projects/work/.agents/skills/computer-use',
resolvedPath: '/home/projects/work/.agents/skills/computer-use',
physicalIdentity: 'physical-repo-computer-use'
@@ -518,57 +514,143 @@ describe('SkillFreshnessUpdateDialog', () => {
expect(row?.textContent).toContain('/home/projects/work/.agents/skills/computer-use')
})
it('names the skill in the stale-record remedy so the command is runnable as-is', async () => {
// Why: the reinstall advice only fires when the row hands its group name through
// to skippedReason — dropping that argument silently degrades every stale-record
// row to the generic sentence with no command to run.
mocks.inventory = {
schemaVersion: 1,
installations: [placement('orchestration')],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: 3
}
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain(
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
)
})
it('keeps the stale-record remedy when a project copy is listed beside it', async () => {
// The same stale-record row as above, plus the user's own project copy. That copy is
// listed but was never judged, so letting it explain the skip replaced the only
// runnable command with advice about a copy the user never asked Orca to update.
it('preselects Orca-owned updates and leaves same-named skills from elsewhere unticked', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orchestration'),
placement('orchestration', {
rootId: 'repo-work',
sourceKind: 'repo',
topology: 'repo-scope',
status: 'unrecognized',
unresolvedPath: '/home/projects/work/.agents/skills/orchestration',
resolvedPath: '/home/projects/work/.agents/skills/orchestration',
physicalIdentity: 'physical-repo-orchestration'
})
placement('computer-use', { lockSource: 'acme/agent-skills' }),
placement('linear-tickets', { lockSource: null }),
placement('orca-cli'),
placement('orchestration', { lockSource: 'https://github.com/stablyai/orca.git' })
],
eligibleUpdateNames: [],
eligibleUpdateNames: ['computer-use', 'linear-tickets', 'orca-cli', 'orchestration'],
scanIssues: [],
scannedAt: 6
scannedAt: 7
}
await renderDialog()
await openViaRequest()
const row = container?.querySelector('[data-skill-row="orchestration"]')
expect(row?.textContent).toContain(
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
// Orca's own rows sort first, and only they are checked.
const rows = Array.from(container?.querySelectorAll('[data-skill-row]') ?? [])
expect(rows.map((row) => row.getAttribute('data-skill-row'))).toEqual([
'orca-cli',
'orchestration',
'computer-use',
'linear-tickets'
])
const checked = (name: string): string | null | undefined =>
container
?.querySelector(`[role="checkbox"][aria-label="Include ${name}"]`)
?.getAttribute('aria-checked')
expect(checked('orca-cli')).toBe('true')
expect(checked('orchestration')).toBe('true')
expect(checked('computer-use')).toBe('false')
expect(checked('linear-tickets')).toBe('false')
expect(container?.querySelector('[data-skill-row="computer-use"]')?.textContent).toContain(
'Not from Orca'
)
expect(row?.textContent).not.toContain('This is a project skill, not a global one')
// Still listed, though — ownership silences the explanation, never the location.
expect(row?.textContent).toContain('/home/projects/work/.agents/skills/orchestration')
expect(container?.querySelector('[data-skill-row="orca-cli"]')?.textContent).toContain(
'Update available'
)
expect(container?.textContent).toContain('2 updates available')
expect(container?.textContent).toContain('2 skills not installed by Orca are unselected.')
await clickButton('Update 2 skills')
expect(skillsApi.startUpdateRun).toHaveBeenLastCalledWith(['orca-cli', 'orchestration'])
})
it('counts the selection in the button once the user ticks a foreign copy', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('computer-use', { lockSource: 'acme/agent-skills' }),
placement('orca-cli')
],
eligibleUpdateNames: ['computer-use', 'orca-cli'],
scanIssues: [],
scannedAt: 8
}
await renderDialog()
await openViaRequest()
const foreign = container?.querySelector('[aria-label="Include computer-use"]')
await act(async () => {
foreign?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(foreign?.getAttribute('aria-checked')).toBe('true')
expect(container?.textContent).not.toContain('not installed by Orca')
await clickButton('Update 2 skills')
// Display order, Orca's first; the runner canonicalizes.
expect(skillsApi.startUpdateRun).toHaveBeenLastCalledWith(['orca-cli', 'computer-use'])
})
it('disables the update once nothing is selected', async () => {
await renderDialog()
await openViaRequest()
await act(async () => {
container
?.querySelector('[aria-label="Include orca-cli"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(findButton('Update 0 skills')).toBeUndefined()
expect(findButton('Update skills')?.disabled).toBe(true)
})
it('drops a choice when a re-check finds a different install source', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('computer-use', { lockSource: 'acme/agent-skills' }),
placement('orca-cli')
],
eligibleUpdateNames: ['computer-use', 'orca-cli'],
scanIssues: [],
scannedAt: 10
}
await renderDialog()
await openViaRequest()
await act(async () => {
container
?.querySelector('[aria-label="Include computer-use"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
// The re-check now attributes orca-cli to someone else: its preselection was Orca's,
// not the user's, so it must not be replaced without a fresh tick.
mocks.inventory = {
...mocks.inventory,
installations: [
placement('computer-use', { lockSource: 'acme/agent-skills' }),
placement('orca-cli', { lockSource: 'acme/agent-skills' })
],
scannedAt: 11
}
await rerender()
expect(
container?.querySelector('[aria-label="Include orca-cli"]')?.getAttribute('aria-checked')
).toBe('false')
await clickButton('Update 1 skill')
expect(skillsApi.startUpdateRun).toHaveBeenLastCalledWith(['computer-use'])
})
it('reads Orca’s skills as current when only a foreign copy is outdated', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('computer-use', { lockSource: null }),
placement('orca-cli', { status: 'current' })
],
eligibleUpdateNames: ['computer-use'],
scanIssues: [],
scannedAt: 9
}
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
expect(container?.textContent).toContain('1 skill not installed by Orca is unselected.')
expect(findButton('Update skills')?.disabled).toBe(true)
})
it('stays coherent while an idle re-scan is in flight', async () => {
@@ -664,7 +746,7 @@ describe('SkillFreshnessUpdateDialog', () => {
failedNames: ['orca-cli'],
finishedAt: 3,
output: '',
message: 'skills update exited with code 1'
message: 'skills add exited with code 1'
})
// The settling re-scan has emptied `eligibleUpdateNames` by the time Retry
// is on screen; retrying that list would spawn nothing at all.
@@ -721,9 +803,7 @@ describe('SkillFreshnessUpdateDialog', () => {
it('shows incomplete plugin coverage without presenting a fabricated skill copy', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
],
installations: [placement('orca-cli', { status: 'current' })],
eligibleUpdateNames: [],
scanIssues: [
{
@@ -759,9 +839,7 @@ describe('SkillFreshnessUpdateDialog', () => {
async (reason) => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
],
installations: [placement('orca-cli', { status: 'current' })],
eligibleUpdateNames: [],
scanIssues: [
{
@@ -793,9 +871,7 @@ describe('SkillFreshnessUpdateDialog', () => {
it('lists a traversal bound without headlining it', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
],
installations: [placement('orca-cli', { status: 'current' })],
eligibleUpdateNames: [],
scanIssues: [
{
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { AlertTriangle, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw } from 'lucide-react'
import {
buildTargetedSkillUpdateCommand,
canonicalizeSkillUpdateNames,
isSkillScanIssueNeedingAttention,
isSkillScanIssueTruncatingScan,
type SkillFreshnessInventory
@@ -9,6 +9,7 @@ import {
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { buildAgentFeatureSkillInstallCommand } from '@/lib/agent-feature-install-commands'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import {
@@ -21,9 +22,14 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { TooltipProvider } from '@/components/ui/tooltip'
import { groupSkillFreshness } from './skill-freshness-grouping'
import { useSkillUpdateSelection } from './use-skill-update-selection'
import { SkillFreshnessScanIssues } from './skill-freshness-scan-issues'
import { SkillUpdateRow } from './SkillUpdateRow'
import { SummaryHeadline, summarizeInventory } from './skill-freshness-summary-headline'
import {
SummaryHeadline,
summarizeInventory,
updateActionLabel
} from './skill-freshness-summary-headline'
import {
acknowledgeSkillUpdateRun,
cancelSkillUpdateRun,
@@ -54,7 +60,7 @@ function RunLog({ output }: { output: string }): React.JSX.Element | null {
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 min-w-0">
{/* Displayed verbatim, never parsed — `skills update` has no --json. */}
{/* Displayed verbatim, never parsed — `skills add` has no --json. */}
<pre className="scrollbar-sleek max-h-40 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md border border-border bg-muted px-3 py-2.5 font-mono text-[11px] leading-relaxed text-muted-foreground">
{output.trim()}
</pre>
@@ -84,10 +90,9 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
lastInventoryRef.current = state.inventory
}
const inventory = state.inventory ?? (state.loading ? lastInventoryRef.current : null)
const eligibleNames = useMemo(() => state.inventory?.eligibleUpdateNames ?? [], [state.inventory])
// Display only. The action still fires `eligibleNames`, so a re-scan in flight
// can never authorize work — but the button keeps its place and its label
// instead of vanishing and reflowing the footer every time one runs.
// Display only. The button stays disabled until a live scan lands, so a re-scan in
// flight can never authorize work — but it keeps its place and its label instead of
// vanishing and reflowing the footer every time one runs.
const displayEligibleCount = inventory?.eligibleUpdateNames.length ?? 0
const isRunning = run.state === 'running'
// The kill sweep can take seconds; without this the Stop button sits enabled
@@ -105,6 +110,7 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
: [],
[inventory, runNames]
)
const selection = useSkillUpdateSelection(groups)
const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update')
const blockedCount = groups.filter((group) => group.status === 'cannot-update').length
// Retained: the list keeps the last known folders on screen through a re-scan, the
@@ -116,6 +122,7 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
// available". Live means it says "Checking…" over the rows it kept on screen.
const summaryKind = summarizeInventory(
state.inventory,
selection.ownedCount,
hasBlockedGroup,
(state.inventory?.scanIssues ?? []).some(
(issue) => isSkillScanIssueNeedingAttention(issue) || isSkillScanIssueTruncatingScan(issue)
@@ -151,6 +158,7 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
// status-bar segment carries it from here.
consumeSkillFreshnessUpdateDialogRequest()
setCopied(false)
selection.reset()
// Don't carry a finished session's rows into the next open — but a live run
// keeps its own, or reopening from the status segment mid-run would land on
// an empty list while the close's own re-scan is still reading disk.
@@ -168,16 +176,14 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
}
const handleCopyCommand = (): void => {
const command = buildTargetedSkillUpdateCommand(
run.state === 'error' ? run.failedNames : eligibleNames
)
if (!command) {
const names = run.state === 'error' ? canonicalizeSkillUpdateNames(run.failedNames) : null
if (!names) {
return
}
// Clipboard writes reject on a denied permission or an unfocused document;
// without this the button just never flips to "Copied".
void navigator.clipboard
.writeText(command)
.writeText(buildAgentFeatureSkillInstallCommand(names))
.then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
@@ -259,8 +265,9 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
return (
<SummaryHeadline
kind={summaryKind}
eligibleCount={eligibleNames.length}
eligibleCount={selection.ownedCount}
blockedCount={blockedCount}
unselectedForeignCount={selection.unselectedForeignCount}
/>
)
})()
@@ -308,7 +315,16 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
<div className={`min-w-0 ${isRunning ? '' : 'border-t border-border/60'}`}>
<TooltipProvider>
{rows.map((row) => (
<SkillUpdateRow key={row.group.name} group={row.group} state={row.state} />
<SkillUpdateRow
key={row.group.name}
group={row.group}
state={row.state}
selection={
row.state === 'available' && !isRunning && !showResult
? selection.checkboxFor(row.group)
: undefined
}
/>
))}
</TooltipProvider>
</div>
@@ -408,8 +424,8 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
<Button
type="button"
size="sm"
disabled={isRunning || eligibleNames.length === 0}
onClick={() => handleUpdate(eligibleNames)}
disabled={isRunning || !state.inventory || selection.selectedNames.length === 0}
onClick={() => handleUpdate(selection.selectedNames)}
>
{/* Not during a stop: the Stop button already carries the status,
and "Updating…" beside "Stopping…" says both at once. */}
@@ -418,16 +434,7 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
'auto.components.skills.SkillFreshnessUpdateDialog.updating',
'Updating…'
)
: displayEligibleCount === 1
? translate(
'auto.components.skills.SkillFreshnessUpdateDialog.updateActionOne',
'Update 1 skill'
)
: translate(
'auto.components.skills.SkillFreshnessUpdateDialog.updateActionMany',
'Update {{value0}} skills',
{ value0: displayEligibleCount }
)}
: updateActionLabel(selection.selectedNames.length)}
</Button>
) : null}
</div>
@@ -0,0 +1,118 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SkillFreshnessGroupModel } from './skill-freshness-grouping'
import { SkillUpdateRow } from './SkillUpdateRow'
// Why: render tooltip content inline so the copy it carries can be asserted.
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children?: ReactNode }) => (
<div data-tooltip-content>{children}</div>
)
}))
let root: Root | null = null
let container: HTMLDivElement | null = null
function group(overrides: Partial<SkillFreshnessGroupModel> = {}): SkillFreshnessGroupModel {
return {
name: 'computer-use',
status: 'update-available',
orcaOwned: false,
lockSource: 'acme/agent-skills',
locations: [
{
id: 'location',
path: '/home/.agents/skills/computer-use',
chip: null,
participatesInGlobalFreshness: true
}
],
...overrides
}
}
async function render(element: React.JSX.Element): Promise<void> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(element)
})
}
function tooltipText(): string | undefined {
return container?.querySelector('[data-tooltip-content]')?.textContent ?? undefined
}
afterEach(async () => {
if (root) {
await act(async () => root?.unmount())
}
root = null
container?.remove()
container = null
})
describe('SkillUpdateRow', () => {
it('labels a copy from another source quietly and names that source', async () => {
await render(<SkillUpdateRow group={group()} state="available" />)
expect(container?.textContent).toContain('Not from Orca')
expect(container?.textContent).not.toContain('Update available')
expect(tooltipText()).toBe(
'Installed from acme/agent-skills. Selecting it replaces it with Orca’s version.'
)
// Why: the source is only in the tooltip, so keyboard focus must be able to open it.
const label = Array.from(container?.querySelectorAll('[tabindex="0"]') ?? []).find((node) =>
node.textContent?.includes('Not from Orca')
)
expect(label).toBeDefined()
})
it('says there is no install record when the lock has no entry', async () => {
await render(<SkillUpdateRow group={group({ lockSource: null })} state="available" />)
expect(tooltipText()).toBe(
'No Orca install record for this copy. Selecting it replaces it with Orca’s version.'
)
})
it('keeps the update badge for Orca’s own skill', async () => {
await render(
<SkillUpdateRow
group={group({ name: 'orca-cli', orcaOwned: true, lockSource: 'stablyai/orca' })}
state="available"
/>
)
expect(container?.textContent).toContain('Update available')
expect(container?.textContent).not.toContain('Not from Orca')
})
it('renders a checkbox only while the user is choosing', async () => {
const onCheckedChange = vi.fn()
await render(
<SkillUpdateRow
group={group()}
state="available"
selection={{ checked: false, onCheckedChange }}
/>
)
const checkbox = container?.querySelector('[role="checkbox"]')
expect(checkbox?.getAttribute('aria-label')).toBe('Include computer-use')
await act(async () => {
checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onCheckedChange).toHaveBeenCalledWith(true)
await act(async () => {
root?.render(<SkillUpdateRow group={group()} state="pending" />)
})
expect(container?.querySelector('[role="checkbox"]')).toBeNull()
})
})
@@ -1,7 +1,9 @@
import { AlertTriangle, CheckCircle2, ChevronDown, Circle, XCircle } from 'lucide-react'
import { AlertTriangle, CheckCircle2, ChevronDown, Circle, Info, XCircle } from 'lucide-react'
import type { SkillFreshnessGroupModel } from './skill-freshness-grouping'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { chipLabel, chipTooltip } from './skill-location-chip-copy'
@@ -28,8 +30,27 @@ function StateIcon({ state }: { state: SkillRowState }): React.JSX.Element | nul
}
}
function notFromOrcaTooltip(lockSource: string | null): string {
return lockSource
? translate(
'auto.components.skills.SkillUpdateRow.notFromOrcaSourceTip',
'Installed from {{value0}}. Selecting it replaces it with Orca’s version.',
{ value0: lockSource }
)
: translate(
'auto.components.skills.SkillUpdateRow.notFromOrcaNoRecordTip',
'No Orca install record for this copy. Selecting it replaces it with Orca’s version.'
)
}
/** Sits immediately right of the name, so the label reads as part of it. */
function StateBadge({ state }: { state: SkillRowState }): React.JSX.Element | null {
function StateBadge({
state,
group
}: {
state: SkillRowState
group: SkillFreshnessGroupModel
}): React.JSX.Element | null {
if (state === 'blocked') {
return (
<Badge
@@ -40,6 +61,25 @@ function StateBadge({ state }: { state: SkillRowState }): React.JSX.Element | nu
</Badge>
)
}
if (state === 'available' && !group.orcaOwned) {
// Why: quiet on purpose; the copy is offered, not recommended.
return (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="inline-flex shrink-0 cursor-help items-center gap-1 rounded-sm text-xs font-normal text-muted-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{translate('auto.components.skills.SkillUpdateRow.notFromOrca', 'Not from Orca')}
<Info className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-pretty">
{notFromOrcaTooltip(group.lockSource)}
</TooltipContent>
</Tooltip>
)
}
if (state === 'available') {
return (
<Badge variant="secondary" className="shrink-0">
@@ -66,10 +106,13 @@ function StateBadge({ state }: { state: SkillRowState }): React.JSX.Element | nu
*/
export function SkillUpdateRow({
group,
state
state,
selection
}: {
group: SkillFreshnessGroupModel
state: SkillRowState
/** Present only while the user is choosing what to update. */
selection?: { checked: boolean; onCheckedChange: (checked: boolean) => void }
}): React.JSX.Element {
const locationCount = group.locations.length
return (
@@ -80,29 +123,46 @@ export function SkillUpdateRow({
// name itself still lines up with the dialog's other content.
className="-mx-1.5 border-t border-border/60 py-0.5 first:border-t-0"
>
<CollapsibleTrigger
className={`group flex w-full min-w-0 items-center gap-3 rounded-md px-1.5 py-2 text-left transition-opacity hover:bg-accent/60 ${
state === 'pending' ? 'opacity-60' : ''
}`}
<div
className={cn(
'flex min-w-0 items-center transition-opacity',
state === 'pending' && 'opacity-60'
)}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<StateIcon state={state} />
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
{group.name}
{/* Why: outside the trigger, so ticking a row never toggles its disclosure. */}
{selection ? (
<Checkbox
className="ml-1.5"
checked={selection.checked}
onCheckedChange={(checked) => selection.onCheckedChange(checked === true)}
aria-label={translate(
'auto.components.skills.SkillUpdateRow.includeSkill',
'Include {{value0}}',
{ value0: group.name }
)}
/>
) : null}
<CollapsibleTrigger variant="row" className="min-w-0">
{/* Margins, not trigger padding, inset the content inside the hover surface. */}
<span className="ml-1.5 flex min-w-0 flex-1 items-center gap-2">
<StateIcon state={state} />
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
{group.name}
</span>
<StateBadge state={state} group={group} />
</span>
<StateBadge state={state} />
</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{locationCount === 1
? translate('auto.components.skills.SkillUpdateRow.oneLocation', '1 location')
: translate(
'auto.components.skills.SkillUpdateRow.manyLocations',
'{{value0}} locations',
{ value0: locationCount }
)}
</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<span className="shrink-0 text-[11px] font-normal tabular-nums text-muted-foreground">
{locationCount === 1
? translate('auto.components.skills.SkillUpdateRow.oneLocation', '1 location')
: translate(
'auto.components.skills.SkillUpdateRow.manyLocations',
'{{value0}} locations',
{ value0: locationCount }
)}
</span>
<ChevronDown className="mr-1.5 size-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
</div>
{state === 'failed' ? (
<p className="px-1.5 pb-1.5 text-xs leading-5 text-muted-foreground">
@@ -118,7 +178,7 @@ export function SkillUpdateRow({
nor on a mount-time `defaultOpen` that a re-scan can't re-fire. */}
{state === 'blocked' ? (
<p className="px-1.5 pb-1.5 text-xs leading-5 text-muted-foreground">
{skippedReason(group.locations, group.name)}
{skippedReason(group.locations)}
</p>
) : null}
@@ -18,12 +18,8 @@ function placement(
physicalIdentity: `physical-${name}`,
topology: 'canonical-copy',
status: 'outdated',
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
lockSource: 'stablyai/orca',
errorCategory: null,
...overrides
}
@@ -33,7 +29,12 @@ describe('groupSkillFreshness', () => {
it('marks an eligible outdated skill as update-available with one location', () => {
const groups = groupSkillFreshness([placement('orca-cli')], ['orca-cli'])
expect(groups).toHaveLength(1)
expect(groups[0]).toMatchObject({ name: 'orca-cli', status: 'update-available' })
expect(groups[0]).toMatchObject({
name: 'orca-cli',
status: 'update-available',
orcaOwned: true,
lockSource: 'stablyai/orca'
})
expect(groups[0]?.locations).toEqual([
{
id: expect.any(String),
@@ -44,6 +45,27 @@ describe('groupSkillFreshness', () => {
])
})
it('sorts Orca-owned skills first, then by name', () => {
const groups = groupSkillFreshness(
[
placement('computer-use', { lockSource: 'acme/agent-skills' }),
placement('linear-tickets', { lockSource: null }),
placement('orchestration'),
placement('orca-cli', { lockSource: 'https://github.com/StablyAI/orca.git' })
],
['computer-use', 'linear-tickets', 'orca-cli', 'orchestration']
)
expect(groups.map((group) => [group.name, group.orcaOwned])).toEqual([
['orca-cli', true],
['orchestration', true],
['computer-use', false],
['linear-tickets', false]
])
expect(groups.find((group) => group.name === 'computer-use')?.lockSource).toBe(
'acme/agent-skills'
)
})
it('hides skills whose every copy is current', () => {
const groups = groupSkillFreshness(
[
@@ -56,21 +78,19 @@ describe('groupSkillFreshness', () => {
})
it('keeps a plugin-managed copy of a same-named skill out of the list', () => {
// Why: the vendor owns that copy, so it is not drift the user can act on —
// reporting it put permanent amber on any ordinary plugin install.
// Why: without a list of past releases a differing plugin copy cannot be told from
// another vendor's skill, and Orca never writes there.
const groups = groupSkillFreshness(
[placement('dataviz', { status: 'unrecognized', topology: 'plugin-cache' })],
[placement('dataviz', { status: 'outdated', topology: 'plugin-cache' })],
[]
)
expect(groups).toEqual([])
})
it('lists a skill whose only fault cannot be fixed by the update, so Details explains it', () => {
// Why: these turn the setup-rail badge amber and its Details opens this list. Omitting
// them left that list headlined "all up to date" over nothing, contradicting the badge.
const groups = groupSkillFreshness(
[
placement('dataviz', { status: 'unrecognized', topology: 'independent-copy' }),
placement('dataviz', { topology: 'independent-copy' }),
placement('linear-tickets', { status: 'inaccessible' })
],
[]
@@ -79,11 +99,13 @@ describe('groupSkillFreshness', () => {
{
name: 'dataviz',
status: 'cannot-update',
orcaOwned: true,
lockSource: 'stablyai/orca',
locations: [
{
id: expect.any(String),
path: '/home/.agents/skills/dataviz',
chip: 'unrecognized',
chip: 'duplicate',
participatesInGlobalFreshness: true
}
]
@@ -91,6 +113,8 @@ describe('groupSkillFreshness', () => {
{
name: 'linear-tickets',
status: 'cannot-update',
orcaOwned: true,
lockSource: 'stablyai/orca',
locations: [
{
id: expect.any(String),
@@ -103,21 +127,6 @@ describe('groupSkillFreshness', () => {
])
})
it('lists an edited canonical copy, which is what an update would overwrite', () => {
const groups = groupSkillFreshness([placement('orchestration', { status: 'unrecognized' })], [])
expect(groups).toHaveLength(1)
expect(groups[0]?.status).toBe('cannot-update')
expect(groups[0]?.locations[0]?.chip).toBe('unrecognized')
})
it('raises no row for a copy that is ahead of this build', () => {
// Why: 'newer-known' is recognized official content — the updater's own install
// or a newer release's bytes. The badge stays green for it, so a row here would
// recreate the badge/dialog disagreement #11128 removed, from the other side.
const groups = groupSkillFreshness([placement('orchestration', { status: 'newer-known' })], [])
expect(groups).toEqual([])
})
it('groups a blocked skill and flags the culprit location, not the main copy', () => {
const groups = groupSkillFreshness(
[
@@ -125,15 +134,14 @@ describe('groupSkillFreshness', () => {
placement('orchestration', {
rootId: 'home-claude',
unresolvedPath: '/home/.claude/skills/orchestration',
status: 'unrecognized',
topology: 'independent-copy'
topology: 'external-link'
})
],
[]
)
expect(groups).toHaveLength(1)
expect(groups[0]?.status).toBe('cannot-update')
// Why: the out-of-date main copy is bare; only the poisoning copy carries a chip.
// Why: the out-of-date main copy is bare; only the blocking copy carries a chip.
expect(groups[0]?.locations).toEqual([
{
id: expect.any(String),
@@ -144,15 +152,13 @@ describe('groupSkillFreshness', () => {
{
id: expect.any(String),
path: '/home/.claude/skills/orchestration',
chip: 'unrecognized',
chip: 'external-link',
participatesInGlobalFreshness: true
}
])
})
it('blocks a skill whose only outdated copy is an unreachable duplicate', () => {
// Why: the global command reports "already up to date" here, so the group must
// read as skipped with the duplicate flagged rather than promising an update.
const groups = groupSkillFreshness(
[
placement('orchestration', { status: 'current' }),
@@ -182,7 +188,7 @@ describe('groupSkillFreshness', () => {
])
})
it('ranks a read failure over ownership, ownership over byte status, and maps every topology', () => {
it('ranks a read failure over topology and maps every topology', () => {
const chipFor = (overrides: Partial<SkillFreshnessInstallation>): string | null =>
groupSkillFreshness(
[placement('s', { status: 'outdated' }), placement('s', overrides)],
@@ -192,9 +198,6 @@ describe('groupSkillFreshness', () => {
unresolvedPath: `/culprit/${path}`,
...rest
})
expect(chipFor(at('a', { status: 'unrecognized', topology: 'independent-copy' }))).toBe(
'unrecognized'
)
expect(chipFor(at('b', { status: 'inaccessible', topology: 'read-only' }))).toBe('inaccessible')
expect(chipFor(at('c', { topology: 'independent-copy' }))).toBe('duplicate')
expect(chipFor(at('d', { topology: 'external-link' }))).toBe('external-link')
@@ -203,30 +206,13 @@ describe('groupSkillFreshness', () => {
expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo')
expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache')
expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current')
// Why: a bare chip is what the reason copy reads as "behind, and a reinstall fixes
// it". A copy that is ahead must be told apart, or that advice rolls it back.
expect(chipFor(at('k', { status: 'newer-known', topology: 'canonical-copy' }))).toBe('newer')
expect(chipFor(at('j', { status: 'unrecognized', topology: 'plugin-cache' }))).toBe(
'plugin-cache'
)
// Ownership outranks byte status: a project copy whose bytes match nothing known is
// the repo's content, not the user's drift, so it must not read "may be modified".
expect(chipFor(at('l', { status: 'unrecognized', topology: 'repo-scope' }))).toBe('in-a-repo')
// But a read failure outranks ownership, or that same rule would hide a real fault.
expect(chipFor(at('k', { status: 'outdated', topology: 'provider-alias' }))).toBeNull()
expect(chipFor(at('m', { status: 'inaccessible', topology: 'repo-scope' }))).toBe(
'inaccessible'
)
})
it('raises no group when every finding is project-owned', () => {
// Orca's updater only passes --global, so a project copy has no remedy; a row here
// would claim Orca considered an update it could never perform.
expect(
groupSkillFreshness(
[placement('computer-use', { status: 'unrecognized', topology: 'repo-scope' })],
[]
)
).toEqual([])
expect(
groupSkillFreshness(
[placement('computer-use', { status: 'outdated', topology: 'repo-scope' })],
@@ -240,7 +226,7 @@ describe('groupSkillFreshness', () => {
[
placement('computer-use', { status: 'outdated', topology: 'read-only' }),
placement('computer-use', {
status: 'unrecognized',
status: 'outdated',
topology: 'repo-scope',
unresolvedPath: '/home/projects/work/.agents/skills/computer-use'
})
@@ -252,10 +238,7 @@ describe('groupSkillFreshness', () => {
expect(groups[0]?.locations.map((location) => location.chip)).toContain('in-a-repo')
})
it('marks only the project copy as one the global update never judged', () => {
// Why the flag rather than the chip: the skip sentence reads this to keep a listed-only
// copy from explaining a skip it had no part in, and a plugin copy — which does earn
// groups of its own — has to stay on the judged side of that line.
it('marks project and plugin copies as ones the global update never judged', () => {
const groups = groupSkillFreshness(
[
placement('computer-use', { status: 'outdated', topology: 'read-only' }),
@@ -265,7 +248,7 @@ describe('groupSkillFreshness', () => {
unresolvedPath: '/home/.claude/plugins/cache/pack/skills/computer-use'
}),
placement('computer-use', {
status: 'unrecognized',
status: 'outdated',
topology: 'repo-scope',
unresolvedPath: '/home/projects/work/.agents/skills/computer-use'
})
@@ -280,7 +263,7 @@ describe('groupSkillFreshness', () => {
])
).toEqual([
['read-only', true],
['plugin-cache', true],
['plugin-cache', false],
['in-a-repo', false]
])
})
@@ -1,5 +1,5 @@
import {
isOwnerManagedSkillScope,
isOrcaSkillLockSource,
isSkillCopyNeedingAttention,
skillPlacementParticipatesInGlobalFreshness,
type SkillFreshnessInstallation
@@ -9,8 +9,6 @@ export type SkillGroupStatus = 'update-available' | 'cannot-update'
export type SkillLocationChip =
| 'current'
| 'newer'
| 'unrecognized'
| 'inaccessible'
| 'duplicate'
| 'external-link'
@@ -31,19 +29,16 @@ export type SkillLocationRow = {
export type SkillFreshnessGroupModel = {
name: string
status: SkillGroupStatus
/** The skills.sh lock says Orca installed this name, so its update is preselected. */
orcaOwned: boolean
lockSource: string | null
locations: SkillLocationRow[]
}
// Why: an owner-managed scope outranks 'unrecognized', or a project copy reads "may be
// modified — remove it" over content its own repo owns. A read failure outranks both, so
// that rule cannot hide a genuine fault.
export function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null {
if (installation.status === 'inaccessible') {
return 'inaccessible'
}
if (!isOwnerManagedSkillScope(installation.topology) && installation.status === 'unrecognized') {
return 'unrecognized'
}
switch (installation.topology) {
case 'independent-copy':
return 'duplicate'
@@ -59,28 +54,22 @@ export function locationChip(installation: SkillFreshnessInstallation): SkillLoc
return 'plugin-cache'
case 'canonical-copy':
case 'provider-alias':
// Why: a supported location only needs a chip when the update won't touch it —
// already current, or ahead of what this build knows. The out-of-date main copy
// is bare, and 'newer' is what keeps a bare chip meaning "behind, and fixable":
// reinstalling a copy that is ahead would quietly roll the user back.
if (installation.status === 'current') {
return 'current'
}
return installation.status === 'newer-known' ? 'newer' : null
// Why: the out-of-date copy the update rewrites is the bare one.
return installation.status === 'current' ? 'current' : null
}
}
/**
* Groups installations by skill for the review modal and derives each skill's
* update disposition. A skill appears when a copy is out of date, or when a copy is
* wrong in a way the update cannot fix — an edited copy, one Orca could not read.
* wrong in a way the update cannot fix — a link out of tree, one Orca could not read.
*
* That second half is what the badge already reports: it turns amber and offers
* Details, and Details opens this modal. Returning only out-of-date skills left that
* modal saying every skill was up to date over an empty list, contradicting the badge
* that sent the user there. Skills where every copy is current are still omitted, as
* is a plugin's own copy of a same-named skill, and a project's own copy — neither is the
* user's to fix through the global update.
* is a plugin's or a project's own copy — neither is the user's to fix through the global
* update. Skills Orca installed sort first, since only they are preselected.
*
* `alwaysIncludeNames` overrides that filter. A successful update makes every
* targeted skill current, which would otherwise drop its row the instant the
@@ -103,10 +92,10 @@ export function groupSkillFreshness(
const groups: SkillFreshnessGroupModel[] = []
for (const [name, entries] of byName) {
// Why: the inclusion test only consults placements the global update can reach, so a
// skill whose only finding is a project-owned copy raises no row at all — a "Skipped"
// row would assert Orca considered an update it never could. Locations below are still
// listed in full, so a project copy stays visible whenever some other placement earns
// the group.
// skill whose only finding is a project's or plugin's own copy raises no row at all — a
// "Skipped" row would assert Orca considered an update it never could. Locations below
// are still listed in full, so such a copy stays visible whenever some other placement
// earns the group.
if (
!pinned.has(name) &&
!entries
@@ -123,11 +112,17 @@ export function groupSkillFreshness(
participatesInGlobalFreshness: skillPlacementParticipatesInGlobalFreshness(entry)
}))
.sort((left, right) => left.path.localeCompare(right.path, 'en'))
const lockSource = entries[0]?.lockSource ?? null
groups.push({
name,
status: eligible.has(name) ? 'update-available' : 'cannot-update',
orcaOwned: isOrcaSkillLockSource(lockSource),
lockSource,
locations
})
}
return groups.sort((left, right) => left.name.localeCompare(right.name, 'en'))
return groups.sort(
(left, right) =>
Number(right.orcaOwned) - Number(left.orcaOwned) || left.name.localeCompare(right.name, 'en')
)
}
@@ -25,69 +25,26 @@ describe('skippedReason', () => {
})
it('leads with the harder blocker when several placements are off', () => {
// Why: an edited copy is the real cause; the duplicate is the lesser symptom, and
// telling the user to remove the duplicate would not unblock the update.
expect(skippedReason([row('duplicate'), row('unrecognized')])).toContain(
'doesn’t match the official version'
)
expect(skippedReason([row('duplicate'), row('read-only')])).toContain('read-only location')
})
it('hands over the reinstall command when no location is at fault', () => {
// Why: the only way to reach this with a bare out-of-date copy is the updater's own
// record, which `skills update` can never converge — so the sentence has to give the
// one command that does, not report a skip the user cannot act on.
const reason = skippedReason([row(null)], 'orchestration')
expect(reason).toContain('reports the skill as already up to date')
expect(reason).toContain(
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
expect(skippedReason([row('duplicate'), row('external-link')])).toContain(
'pointing outside Orca’s skill folders'
)
})
it('never offers the reinstall for a copy that is ahead of this build', () => {
// Why: reinstalling a newer copy rolls the user back to what this build ships.
const reason = skippedReason([row('newer')], 'orchestration')
expect(reason).toContain('later version')
expect(reason).not.toContain('skills add')
})
it('keeps a placement blocker ahead of the record advice', () => {
expect(skippedReason([row(null), row('unrecognized')], 'orchestration')).toContain(
'doesn’t match the official version'
)
})
it('keeps the reinstall remedy when a project copy sits beside the blocked global copy', () => {
// The reported shape: a bare out-of-date global copy the updater's record cannot
// converge, plus the user's own project copy. Letting the project copy explain the
// skip swapped the one runnable command for advice about a copy Orca never judged.
const reason = skippedReason([row(null), projectRow()], 'orchestration')
expect(reason).toContain('reports the skill as already up to date')
expect(reason).toContain('skills add')
expect(reason).not.toContain('This is a project skill')
})
it('does not let a project copy outrank the placement that blocked the update', () => {
// Why by row and not by chip: the scan-limit sentinel is also project-scoped and
// carries 'inaccessible', which would otherwise claim a read failure Orca never hit.
expect(skippedReason([row('duplicate'), projectRow()], 'orchestration')).toContain(
'separate copy'
)
expect(
skippedReason([row('duplicate'), projectRow('inaccessible')], 'orchestration')
).toContain('separate copy')
expect(skippedReason([row('duplicate'), projectRow()])).toContain('separate copy')
expect(skippedReason([row('duplicate'), projectRow('inaccessible')])).toContain('separate copy')
})
it('explains a project copy when that is the only location there is', () => {
// Why the fallback exists: the sentence stays total. With nothing else to point at,
// "Orca only updates your global skills" is the honest reason.
expect(skippedReason([projectRow()], 'orchestration')).toContain(
'This is a project skill, not a global one'
)
expect(skippedReason([projectRow()])).toContain('This is a project skill, not a global one')
})
it('falls back to the generic sentence when no skill name is available', () => {
it('falls back to the generic sentence when no location is at fault', () => {
expect(skippedReason([row('current')])).toContain('left this skill out of the update')
expect(skippedReason([row(null)])).toContain('left this skill out of the update')
expect(skippedReason([])).toContain('left this skill out of the update')
})
})
@@ -1,17 +1,11 @@
import { buildAgentFeatureSkillInstallCommand } from '../../../../shared/agent-feature-install-commands'
import type { SkillLocationChip, SkillLocationRow } from './skill-freshness-grouping'
import { translate } from '@/i18n/i18n'
// Why: a skill is skipped for one concrete reason; lead with the highest-priority
// blocking placement so the sentence explains the real cause (an edited copy is
// more useful to surface than a downstream symptom).
// blocking placement so the sentence explains the real cause rather than a symptom.
const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [
'unrecognized',
'read-only',
'inaccessible',
// Why: above the placement chips — a copy that is ahead of this build must never
// fall through to the reinstall advice below, which would roll it back.
'newer',
'in-a-repo',
'plugin-cache',
'external-link',
@@ -22,8 +16,7 @@ const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [
]
// Why: a location the global update never judged cannot be why the skill was skipped, so it
// must not outrank the placement that was — nor the ABSENCE of a chip, which is how the
// stale-record remedy below is reached. The fallback keeps the sentence total.
// must not outrank the placement that was. The fallback keeps the sentence total.
function blockingChip(locations: readonly SkillLocationRow[]): SkillLocationChip | undefined {
const judged = locations.filter((location) => location.participatesInGlobalFreshness)
const present = new Set((judged.length > 0 ? judged : locations).map((location) => location.chip))
@@ -37,23 +30,10 @@ function blockingChip(locations: readonly SkillLocationRow[]): SkillLocationChip
* The wording is deictic ("this copy") on purpose: it is only ever rendered beside the
* location rows it describes, which is why the setup rails link into the dialog rather
* than repeating a sentence that would have nothing to point at.
*
* `skillName` is only used for the no-chip case, where the fault is not a placement at
* all but the updater's own record of this skill, and the remedy has to name it.
*/
export function skippedReason(locations: readonly SkillLocationRow[], skillName?: string): string {
export function skippedReason(locations: readonly SkillLocationRow[]): string {
const chip = blockingChip(locations)
switch (chip) {
case 'newer':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonNewer',
'This copy is a later version than the one this build of Orca ships, so Orca left it alone rather than roll it back. Updating Orca will bring the two back in line.'
)
case 'unrecognized':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonUnrecognized',
'The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.'
)
case 'read-only':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonReadOnly',
@@ -91,22 +71,9 @@ export function skippedReason(locations: readonly SkillLocationRow[], skillName?
)
case 'current':
case undefined:
// Why: no location is at fault here — the copy is an ordinary out-of-date one the
// update would happily write. What is wrong is the updater's own record of it:
// `skills update` decides what to do by comparing that record against the source
// and never reads disk, so when the record is missing or already names the version
// it was meant to fetch, the command reports "up to date" and writes nothing. No
// retry converges it; only a reinstall rewrites the record, which is why the
// sentence has to hand over the command rather than say the update was skipped.
return skillName
? translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonStaleRecord',
'The skills updater has no usable record of this copy, so it reports the skill as already up to date and changes nothing. Reinstall it to bring the record back in line: {{value0}}',
{ value0: buildAgentFeatureSkillInstallCommand([skillName]) }
)
: translate(
'auto.components.skills.SkillFreshnessRow.cantUpdateReason',
'Orca left this skill out of the update command.'
)
return translate(
'auto.components.skills.SkillFreshnessRow.cantUpdateReason',
'Orca left this skill out of the update command.'
)
}
}
@@ -12,13 +12,16 @@ export type FreshnessSummaryKind =
export function summarizeInventory(
inventory: SkillFreshnessInventory | null,
ownedEligibleCount: number,
hasBlockedGroup: boolean,
hasIncompleteScan: boolean
): FreshnessSummaryKind {
if (!inventory) {
return 'loading'
}
if (inventory.eligibleUpdateNames.length > 0) {
// Why: only Orca's own skills count as updates; a same-named copy from elsewhere is
// offered unselected under the headline, so Orca's skills can still read up to date.
if (ownedEligibleCount > 0) {
return 'eligible'
}
// Why: a named skill the update can't converge outranks a coverage gap — the gap
@@ -40,8 +43,62 @@ export function summarizeInventory(
return 'current'
}
// Why: the button stays while any row is updatable, so unticking every row doesn't reflow
// the footer; a disabled "Update 0 skills" would read as a broken count.
export function updateActionLabel(count: number): string {
if (count === 0) {
return translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills')
}
return count === 1
? translate(
'auto.components.skills.SkillFreshnessUpdateDialog.updateActionOne',
'Update 1 skill'
)
: translate(
'auto.components.skills.SkillFreshnessUpdateDialog.updateActionMany',
'Update {{value0}} skills',
{ value0: count }
)
}
/** Pre-run headline. Once a run starts, the dialog reports the run instead. */
export function SummaryHeadline({
kind,
eligibleCount,
blockedCount,
unselectedForeignCount
}: {
kind: FreshnessSummaryKind
eligibleCount: number
blockedCount: number
unselectedForeignCount: number
}): React.JSX.Element {
const main = (
<SummaryHeadlineMain kind={kind} eligibleCount={eligibleCount} blockedCount={blockedCount} />
)
if (kind === 'loading' || kind === 'empty' || unselectedForeignCount === 0) {
return main
}
return (
<div className="space-y-1">
{main}
<p className="text-xs text-muted-foreground">
{unselectedForeignCount === 1
? translate(
'auto.components.skills.SkillFreshnessUpdateDialog.unselectedForeignOne',
'1 skill not installed by Orca is unselected.'
)
: translate(
'auto.components.skills.SkillFreshnessUpdateDialog.unselectedForeignMany',
'{{value0}} skills not installed by Orca are unselected.',
{ value0: unselectedForeignCount }
)}
</p>
</div>
)
}
function SummaryHeadlineMain({
kind,
eligibleCount,
blockedCount
@@ -5,10 +5,6 @@ export function chipLabel(chip: SkillLocationChip): string {
switch (chip) {
case 'current':
return translate('auto.components.skills.SkillFreshnessRow.chipCurrent', 'Current')
case 'newer':
return translate('auto.components.skills.SkillFreshnessRow.chipNewer', 'Newer')
case 'unrecognized':
return translate('auto.components.skills.SkillFreshnessRow.chipUnrecognized', 'Unrecognized')
case 'inaccessible':
return translate('auto.components.skills.SkillFreshnessRow.chipInaccessible', 'Inaccessible')
case 'duplicate':
@@ -35,16 +31,6 @@ export function chipTooltip(chip: SkillLocationChip): string {
'auto.components.skills.SkillFreshnessRow.tipCurrent',
'This copy matches the current official version.'
)
case 'newer':
return translate(
'auto.components.skills.SkillFreshnessRow.tipNewer',
'This copy is a later version than the one this build of Orca ships.'
)
case 'unrecognized':
return translate(
'auto.components.skills.SkillFreshnessRow.tipUnrecognized',
'This copy doesn’t match any official version — it may be modified, or a different skill with the same name.'
)
case 'inaccessible':
return translate(
'auto.components.skills.SkillFreshnessRow.tipInaccessible',
@@ -0,0 +1,44 @@
import { useState } from 'react'
import type { SkillFreshnessGroupModel } from './skill-freshness-grouping'
export type SkillUpdateSelection = {
checkboxFor: (group: SkillFreshnessGroupModel) => {
checked: boolean
onCheckedChange: (checked: boolean) => void
}
reset: () => void
selectedNames: string[]
ownedCount: number
unselectedForeignCount: number
}
// Why: a choice is consent for the copy the user saw; if a re-scan finds a different
// install source, the choice lapses back to the ownership default.
function choiceKey(group: SkillFreshnessGroupModel): string {
return `${group.name}\0${group.lockSource ?? ''}`
}
/**
* Which updatable skills the next run rewrites. Orca's own skills are preselected;
* a same-named copy from elsewhere is only replaced once the user ticks it.
*/
export function useSkillUpdateSelection(
groups: readonly SkillFreshnessGroupModel[]
): SkillUpdateSelection {
const [choices, setChoices] = useState<ReadonlyMap<string, boolean>>(new Map())
const isSelected = (group: SkillFreshnessGroupModel): boolean =>
choices.get(choiceKey(group)) ?? group.orcaOwned
const updatable = groups.filter((group) => group.status === 'update-available')
return {
checkboxFor: (group) => ({
checked: isSelected(group),
onCheckedChange: (checked) =>
setChoices((previous) => new Map(previous).set(choiceKey(group), checked))
}),
reset: () => setChoices(new Map()),
selectedNames: updatable.filter(isSelected).map((group) => group.name),
ownedCount: updatable.filter((group) => group.orcaOwned).length,
unselectedForeignCount: updatable.filter((group) => !group.orcaOwned && !isSelected(group))
.length
}
}
+2
View File
@@ -77,6 +77,8 @@ declare module 'monaco-editor/esm/vs/base/common/lifecycle.js' {
}
declare global {
/** Full commit the build was made from, stamped by the renderer build. */
const ORCA_BUILD_COMMIT: string
var MonacoEnvironment:
| {
getWorker(workerId: string, label: string): Worker
+9 -10
View File
@@ -4763,7 +4763,6 @@
"statusUpdateAvailable": "Update available",
"statusCantUpdate": "Skipped",
"cantUpdateReason": "Orca left this skill out of the update command.",
"skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.",
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
"skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.",
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
@@ -4771,7 +4770,6 @@
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.",
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.",
"chipCurrent": "Current",
"chipUnrecognized": "Unrecognized",
"chipInaccessible": "Inaccessible",
"chipDuplicate": "Duplicate",
"chipExternalLink": "External link",
@@ -4780,7 +4778,6 @@
"chipInRepo": "In a repo",
"chipPluginCache": "Plugin cache",
"tipCurrent": "This copy matches the current official version.",
"tipUnrecognized": "This copy doesn’t match any official version — it may be modified, or a different skill with the same name.",
"tipInaccessible": "Orca couldn’t read this copy (a permissions or file error).",
"tipDuplicate": "A separate copy of this skill, installed apart from the main one.",
"tipExternalLink": "A shortcut pointing outside Orca’s skill folders.",
@@ -4788,11 +4785,7 @@
"tipReadOnly": "This copy is in a read-only location.",
"tipInRepo": "This copy lives inside a project, not your global skills.",
"tipPluginCache": "This copy is managed by a plugin.",
"skippedReasonDuplicate": "This is a separate copy, so the update won’t reach it — the command only refreshes the main copy. Remove this copy, then reinstall the skill so this location follows the main one.",
"skippedReasonNewer": "This copy is a later version than the one this build of Orca ships, so Orca left it alone rather than roll it back. Updating Orca will bring the two back in line.",
"skippedReasonStaleRecord": "The skills updater has no usable record of this copy, so it reports the skill as already up to date and changes nothing. Reinstall it to bring the record back in line: {{value0}}",
"chipNewer": "Newer",
"tipNewer": "This copy is a later version than the one this build of Orca ships."
"skippedReasonDuplicate": "This is a separate copy, so the update won’t reach it — the command only refreshes the main copy. Remove this copy, then reinstall the skill so this location follows the main one."
},
"SkillFreshnessUpdateDialog": {
"title": "Update skills",
@@ -4833,14 +4826,20 @@
"scanOutsideRoot": "Orca skipped this plugin path because it points outside the plugin cache.",
"scanIoErrorWithCode": "Orca could not read this plugin path ({{value0}}).",
"scanIoError": "Orca could not read this plugin path.",
"scanIssueLimit": "Orca found too many skipped plugin folders to list individually."
"scanIssueLimit": "Orca found too many skipped plugin folders to list individually.",
"unselectedForeignOne": "1 skill not installed by Orca is unselected.",
"unselectedForeignMany": "{{value0}} skills not installed by Orca are unselected."
},
"SkillUpdateResultRows": {
"stillOutdated": "Still out of date after the update ran."
},
"SkillUpdateRow": {
"oneLocation": "1 location",
"manyLocations": "{{value0}} locations"
"manyLocations": "{{value0}} locations",
"notFromOrca": "Not from Orca",
"notFromOrcaSourceTip": "Installed from {{value0}}. Selecting it replaces it with Orca’s version.",
"notFromOrcaNoRecordTip": "No Orca install record for this copy. Selecting it replaces it with Orca’s version.",
"includeSkill": "Include {{value0}}"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "Update available",
+12 -13
View File
@@ -4583,9 +4583,8 @@
"SkillFreshnessRow": {
"statusUpdateAvailable": "Actualización disponible",
"statusCantUpdate": "No se puede actualizar",
"cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando npx skills update la deja sin cambios.",
"cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando de actualización la deja sin cambios.",
"chipCurrent": "Actual",
"chipUnrecognized": "No reconocida",
"chipInaccessible": "Inaccesible",
"chipDuplicate": "Duplicada",
"chipExternalLink": "Enlace externo",
@@ -4594,26 +4593,20 @@
"chipInRepo": "En un repo",
"chipPluginCache": "Caché de plugin",
"tipCurrent": "La skill aquí ya está actualizada; la actualización no la cambiará.",
"tipUnrecognized": "El contenido de la skill aquí no coincide con ninguna versión oficial, así que Orca no puede actualizarla de forma segura. Elimina o reemplaza lo que hay aquí para permitir las actualizaciones.",
"tipInaccessible": "Orca no pudo leer la skill aquí (un error de permisos o de archivo), así que no puede comprobarla ni actualizarla.",
"tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando npx skills update no puede alcanzarla. Elimínala para permitir las actualizaciones.",
"tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando de actualización no puede alcanzarla. Elimínala para permitir las actualizaciones.",
"tipExternalLink": "Es un acceso directo que apunta fuera de las carpetas de skills de Orca; la actualización no lo seguirá.",
"tipBrokenLink": "Es un acceso directo a algo que ya no existe; puedes eliminarlo sin problema.",
"tipReadOnly": "La skill aquí está en una ubicación de solo lectura, así que no se puede actualizar hasta que cambies sus permisos.",
"tipInRepo": "La skill aquí vive dentro de un proyecto, no en tus skills globales; Orca solo actualiza las globales.",
"tipPluginCache": "La skill aquí la gestiona un plugin; actualiza el plugin en su lugar.",
"skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.",
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
"skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.",
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.",
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.",
"skippedReasonDuplicate": "This is a separate copy, so the update won’t reach it — the command only refreshes the main copy. Remove this copy, then reinstall the skill so this location follows the main one.",
"skippedReasonNewer": "This copy is a later version than the one this build of Orca ships, so Orca left it alone rather than roll it back. Updating Orca will bring the two back in line.",
"skippedReasonStaleRecord": "The skills updater has no usable record of this copy, so it reports the skill as already up to date and changes nothing. Reinstall it to bring the record back in line: {{value0}}",
"chipNewer": "Newer",
"tipNewer": "This copy is a later version than the one this build of Orca ships."
"skippedReasonDuplicate": "Es una copia aparte, así que la actualización no la alcanza: el comando solo actualiza la copia principal. Elimina esta copia y vuelve a instalar la skill para que esta ubicación siga a la principal."
},
"SkillFreshnessUpdateDialog": {
"title": "Actualizar skills",
@@ -4654,7 +4647,9 @@
"scanOutsideRoot": "Orca omitió esta ruta del plugin porque apunta fuera de la caché de plugins.",
"scanIoErrorWithCode": "Orca no pudo leer esta ruta del plugin ({{value0}}).",
"scanIoError": "Orca no pudo leer esta ruta del plugin.",
"scanIssueLimit": "Orca encontró demasiadas carpetas de plugins omitidas para mostrarlas por separado."
"scanIssueLimit": "Orca encontró demasiadas carpetas de plugins omitidas para mostrarlas por separado.",
"unselectedForeignOne": "1 skill no instalada por Orca no está seleccionada.",
"unselectedForeignMany": "{{value0}} skills no instaladas por Orca no están seleccionadas."
},
"SkillFreshnessStatusPill": {
"updateAvailable": "Actualización disponible",
@@ -4669,8 +4664,12 @@
"stillOutdated": "Still out of date after the update ran."
},
"SkillUpdateRow": {
"oneLocation": "1 location",
"manyLocations": "{{value0}} locations"
"oneLocation": "1 ubicación",
"manyLocations": "{{value0}} ubicaciones",
"notFromOrca": "No es de Orca",
"notFromOrcaSourceTip": "Instalada desde {{value0}}. Si la seleccionas, se reemplaza por la versión de Orca.",
"notFromOrcaNoRecordTip": "No hay registro de instalación de Orca para esta copia. Si la seleccionas, se reemplaza por la versión de Orca.",
"includeSkill": "Incluir {{value0}}"
},
"host": {
"local": "Esta máquina",
+9 -10
View File
@@ -4605,7 +4605,6 @@
"statusUpdateAvailable": "Mise à jour disponible",
"statusCantUpdate": "Ignoré",
"cantUpdateReason": "Orca a exclu ce skill de la commande de mise à jour.",
"skippedReasonUnrecognized": "La copie ici ne correspond pas à la version officielle — elle a peut-être été modifiée, ou il s'agit d'un autre skill portant le même nom. Orca l'a exclue de la mise à jour pour ne pas l'écraser. Supprimez-la si vous voulez qu'Orca mette à jour ce skill.",
"skippedReasonReadOnly": "Cette copie se trouve dans un emplacement en lecture seule, donc Orca l'a exclue de la mise à jour. Modifiez ses permissions pour permettre à Orca de la mettre à jour.",
"skippedReasonInaccessible": "Orca n'a pas pu lire cette copie, il a donc exclu le skill de la mise à jour.",
"skippedReasonInRepo": "C'est un skill de projet, pas un skill global — Orca ne met à jour que vos skills globaux, il a donc exclu celui-ci de la mise à jour.",
@@ -4613,7 +4612,6 @@
"skippedReasonExternalLink": "Cette copie est un raccourci pointant hors des dossiers de skills d'Orca, donc Orca l'a exclue de la mise à jour.",
"skippedReasonBrokenLink": "Cette copie est un raccourci vers quelque chose qui n'existe plus, donc Orca l'a exclue — vous pouvez la supprimer sans risque.",
"chipCurrent": "Actuel",
"chipUnrecognized": "Non reconnu",
"chipInaccessible": "Inaccessible",
"chipDuplicate": "Doublon",
"chipExternalLink": "Lien externe",
@@ -4622,7 +4620,6 @@
"chipInRepo": "Dans un dépôt",
"chipPluginCache": "Cache de plugins",
"tipCurrent": "Cette copie correspond à la version officielle actuelle.",
"tipUnrecognized": "Cette copie ne correspond à aucune version officielle — elle a peut-être été modifiée, ou il s'agit d'un autre skill portant le même nom.",
"tipInaccessible": "Orca n'a pas pu lire cette copie (erreur de permissions ou de fichier).",
"tipDuplicate": "Une copie séparée de ce skill, installée à part de la principale.",
"tipExternalLink": "Un raccourci pointant hors des dossiers de skills d'Orca.",
@@ -4630,11 +4627,7 @@
"tipReadOnly": "Cette copie se trouve dans un emplacement en lecture seule.",
"tipInRepo": "Cette copie vit dans un projet, pas dans vos skills globaux.",
"tipPluginCache": "Cette copie est gérée par un plugin.",
"skippedReasonDuplicate": "Il s'agit d'une copie séparée : la mise à jour ne l'atteindra pas — la commande ne rafraîchit que la copie principale. Supprimez cette copie puis réinstallez le skill pour que cet emplacement suive le principal.",
"skippedReasonNewer": "Cette copie est plus récente que celle fournie avec cette build d'Orca ; Orca l'a donc laissée telle quelle plutôt que de la rétrograder. La mise à jour d'Orca remettra les deux en phase.",
"skippedReasonStaleRecord": "Le programme de mise à jour des skills n'a aucun enregistrement exploitable pour cette copie ; il signale donc le skill comme déjà à jour sans rien modifier. Réinstallez-le pour remettre l'enregistrement en cohérence : {{value0}}",
"chipNewer": "Plus récent",
"tipNewer": "Cette copie est plus récente que celle fournie avec cette build d'Orca."
"skippedReasonDuplicate": "Il s'agit d'une copie séparée : la mise à jour ne l'atteindra pas — la commande ne rafraîchit que la copie principale. Supprimez cette copie puis réinstallez le skill pour que cet emplacement suive le principal."
},
"SkillFreshnessUpdateDialog": {
"title": "Mettre à jour les skills",
@@ -4675,14 +4668,20 @@
"scanOutsideRoot": "Orca a ignoré ce chemin de plugin car il pointe hors du cache de plugins.",
"scanIoErrorWithCode": "Orca n'a pas pu lire ce chemin de plugin ({{value0}}).",
"scanIoError": "Orca n'a pas pu lire ce chemin de plugin.",
"scanIssueLimit": "Orca a trouvé trop de dossiers de plugins ignorés pour pouvoir les lister un par un."
"scanIssueLimit": "Orca a trouvé trop de dossiers de plugins ignorés pour pouvoir les lister un par un.",
"unselectedForeignOne": "1 skill non installé par Orca n’est pas sélectionné.",
"unselectedForeignMany": "{{value0}} skills non installés par Orca ne sont pas sélectionnés."
},
"SkillUpdateResultRows": {
"stillOutdated": "Toujours pas à jour après l'exécution de la mise à jour."
},
"SkillUpdateRow": {
"oneLocation": "1 emplacement",
"manyLocations": "{{value0}} emplacements"
"manyLocations": "{{value0}} emplacements",
"notFromOrca": "Hors Orca",
"notFromOrcaSourceTip": "Installé depuis {{value0}}. Le sélectionner le remplace par la version d’Orca.",
"notFromOrcaNoRecordTip": "Aucune trace d’installation par Orca pour cette copie. La sélectionner la remplace par la version d’Orca.",
"includeSkill": "Inclure {{value0}}"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "Mise à jour disponible",
+11 -12
View File
@@ -4583,9 +4583,8 @@
"SkillFreshnessRow": {
"statusUpdateAvailable": "更新があります",
"statusCantUpdate": "スキップ済み",
"cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、npx skills update コマンドは変更しません。",
"cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、更新コマンドは変更しません。",
"chipCurrent": "現在",
"chipUnrecognized": "未認識",
"chipInaccessible": "アクセス不可",
"chipDuplicate": "重複",
"chipExternalLink": "外部リンク",
@@ -4594,26 +4593,20 @@
"chipInRepo": "リポジトリ内",
"chipPluginCache": "プラグインキャッシュ",
"tipCurrent": "ここのスキルはすでに最新です。更新しても変更されません。",
"tipUnrecognized": "ここのスキルの内容が公式バージョンのいずれとも一致しないため、Orca は安全に更新できません。更新を許可するには、ここにあるものを削除または置き換えてください。",
"tipInaccessible": "Orca はここのスキルを読み取れなかったため(権限またはファイルのエラー)、確認も更新もできません。",
"tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため npx skills update コマンドは到達できません。更新を許可するには削除してください。",
"tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため 更新コマンドは到達できません。更新を許可するには削除してください。",
"tipExternalLink": "これは Orca のスキルフォルダーの外を指すショートカットです。更新では追跡されません。",
"tipBrokenLink": "これは存在しないものを指すショートカットです。削除しても問題ありません。",
"tipReadOnly": "ここのスキルは読み取り専用の場所にあるため、権限を変更するまで更新できません。",
"tipInRepo": "ここのスキルはグローバルスキルではなくプロジェクト内にあります。Orca はグローバルなものだけを更新します。",
"tipPluginCache": "ここのスキルはプラグインによって管理されています。代わりにプラグインを更新してください。",
"skippedReasonUnrecognized": "こちらのコピーは公式版と一致しません。変更されているか、同名の別のスキルの可能性があります。上書きしないよう、Orca は更新対象から除外しました。このスキルを Orca に更新させたい場合は、コピーを削除してください。",
"skippedReasonReadOnly": "このコピーは読み取り専用の場所にあるため、Orca は更新対象から除外しました。Orca が更新できるように、権限を変更してください。",
"skippedReasonInaccessible": "Orca はこのコピーを読み取れなかったため、このスキルを更新対象から除外しました。",
"skippedReasonInRepo": "これはグローバルスキルではなくプロジェクトスキルです。Orca はグローバルスキルのみを更新するため、更新対象から除外しました。",
"skippedReasonPluginCache": "このスキルはプラグインが管理しているため、Orca は更新対象から除外しました。代わりにプラグインを更新してください。",
"skippedReasonExternalLink": "このコピーは Orca のスキルフォルダー外を指すショートカットのため、Orca は更新対象から除外しました。",
"skippedReasonBrokenLink": "このコピーは存在しない対象へのショートカットのため、Orca は更新対象から除外しました。削除して問題ありません。",
"skippedReasonDuplicate": "これは別のコピーのため、更新は反映されません。コマンドはメインのコピーのみを更新します。このコピーを削除してからスキルを再インストールすると、この場所もメインに追従します。",
"skippedReasonNewer": "このコピーは、このビルドの Orca に同梱されているものより新しいバージョンです。そのため Orca はロールバックせず、そのままにしました。Orca を更新すると整合します。",
"skippedReasonStaleRecord": "スキル更新機能はこのコピーの記録を利用できないため、スキルは最新であると報告し、何も変更しません。記録を整合させるには再インストールしてください: {{value0}}",
"chipNewer": "Newer",
"tipNewer": "このコピーは、このビルドの Orca に同梱されているものより新しいバージョンです。"
"skippedReasonDuplicate": "これは別のコピーのため、更新は反映されません。コマンドはメインのコピーのみを更新します。このコピーを削除してからスキルを再インストールすると、この場所もメインに追従します。"
},
"SkillFreshnessUpdateDialog": {
"title": "スキルを更新",
@@ -4654,7 +4647,9 @@
"scanOutsideRoot": "プラグインキャッシュの外部を指しているため、このプラグインパスをスキップしました。",
"scanIoErrorWithCode": "このプラグインパスを読み取れませんでした({{value0}})。",
"scanIoError": "このプラグインパスを読み取れませんでした。",
"scanIssueLimit": "スキップされたプラグインフォルダーが多すぎるため、個別に表示できません。"
"scanIssueLimit": "スキップされたプラグインフォルダーが多すぎるため、個別に表示できません。",
"unselectedForeignOne": "Orca 以外からインストールされた 1 件のスキルは選択されていません。",
"unselectedForeignMany": "Orca 以外からインストールされた {{value0}} 件のスキルは選択されていません。"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "更新があります",
@@ -4670,7 +4665,11 @@
},
"SkillUpdateRow": {
"oneLocation": "1 件の場所",
"manyLocations": "{{value0}} 件の場所"
"manyLocations": "{{value0}} 件の場所",
"notFromOrca": "Orca 以外から導入",
"notFromOrcaSourceTip": "{{value0}} からインストールされています。選択すると Orca のバージョンに置き換えられます。",
"notFromOrcaNoRecordTip": "このコピーには Orca によるインストール記録がありません。選択すると Orca のバージョンに置き換えられます。",
"includeSkill": "{{value0}} を含める"
},
"host": {
"local": "このマシン",
+12 -13
View File
@@ -4583,9 +4583,8 @@
"SkillFreshnessRow": {
"statusUpdateAvailable": "업데이트 있음",
"statusCantUpdate": "업데이트할 수 없음",
"cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, npx skills update 명령이 변경하지 않습니다.",
"cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, 업데이트 명령이 변경하지 않습니다.",
"chipCurrent": "최신",
"chipUnrecognized": "인식 안 됨",
"chipInaccessible": "액세스 불가",
"chipDuplicate": "중복",
"chipExternalLink": "외부 링크",
@@ -4594,26 +4593,20 @@
"chipInRepo": "저장소 내",
"chipPluginCache": "플러그인 캐시",
"tipCurrent": "여기 있는 스킬은 이미 최신이므로 업데이트해도 변경되지 않습니다.",
"tipUnrecognized": "여기 있는 스킬의 콘텐츠가 어떤 공식 버전과도 일치하지 않아 Orca가 안전하게 업데이트할 수 없습니다. 업데이트를 허용하려면 여기 있는 항목을 제거하거나 교체하세요.",
"tipInaccessible": "Orca가 여기 있는 스킬을 읽지 못해(권한 또는 파일 오류) 확인하거나 업데이트할 수 없습니다.",
"tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 npx skills update 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.",
"tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 업데이트 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.",
"tipExternalLink": "Orca의 스킬 폴더 바깥을 가리키는 바로 가기입니다. 업데이트가 이를 따라가지 않습니다.",
"tipBrokenLink": "더 이상 존재하지 않는 대상을 가리키는 바로 가기입니다. 삭제해도 됩니다.",
"tipReadOnly": "여기 있는 스킬은 읽기 전용 위치에 있어 권한을 변경해야 업데이트할 수 있습니다.",
"tipInRepo": "여기 있는 스킬은 전역 스킬이 아니라 프로젝트 안에 있습니다. Orca는 전역 스킬만 업데이트합니다.",
"tipPluginCache": "여기 있는 스킬은 플러그인이 관리합니다. 대신 플러그인을 업데이트하세요.",
"skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.",
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
"skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.",
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.",
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.",
"skippedReasonDuplicate": "This is a separate copy, so the update won’t reach it — the command only refreshes the main copy. Remove this copy, then reinstall the skill so this location follows the main one.",
"skippedReasonNewer": "This copy is a later version than the one this build of Orca ships, so Orca left it alone rather than roll it back. Updating Orca will bring the two back in line.",
"skippedReasonStaleRecord": "The skills updater has no usable record of this copy, so it reports the skill as already up to date and changes nothing. Reinstall it to bring the record back in line: {{value0}}",
"chipNewer": "Newer",
"tipNewer": "This copy is a later version than the one this build of Orca ships."
"skippedReasonDuplicate": "별도의 사본이라 업데이트가 적용되지 않습니다. 명령은 기본 사본만 새로 고칩니다. 이 사본을 제거한 뒤 스킬을 다시 설치하면 이 위치도 기본 사본을 따릅니다."
},
"SkillFreshnessUpdateDialog": {
"title": "스킬 업데이트",
@@ -4654,7 +4647,9 @@
"scanOutsideRoot": "플러그인 캐시 외부를 가리키는 플러그인 경로를 건너뛰었습니다.",
"scanIoErrorWithCode": "이 플러그인 경로를 읽지 못했습니다({{value0}}).",
"scanIoError": "이 플러그인 경로를 읽지 못했습니다.",
"scanIssueLimit": "건너뛴 플러그인 폴더가 너무 많아 개별적으로 표시할 수 없습니다."
"scanIssueLimit": "건너뛴 플러그인 폴더가 너무 많아 개별적으로 표시할 수 없습니다.",
"unselectedForeignOne": "Orca에서 설치하지 않은 스킬 1개는 선택되지 않았습니다.",
"unselectedForeignMany": "Orca에서 설치하지 않은 스킬 {{value0}}개는 선택되지 않았습니다."
},
"SkillFreshnessStatusPill": {
"updateAvailable": "업데이트 가능",
@@ -4669,8 +4664,12 @@
"stillOutdated": "Still out of date after the update ran."
},
"SkillUpdateRow": {
"oneLocation": "1 location",
"manyLocations": "{{value0}} locations"
"oneLocation": "위치 1개",
"manyLocations": "위치 {{value0}}개",
"notFromOrca": "Orca에서 설치하지 않음",
"notFromOrcaSourceTip": "{{value0}}에서 설치되었습니다. 선택하면 Orca 버전으로 교체됩니다.",
"notFromOrcaNoRecordTip": "이 사본에는 Orca 설치 기록이 없습니다. 선택하면 Orca 버전으로 교체됩니다.",
"includeSkill": "{{value0}} 포함"
},
"host": {
"local": "이 컴퓨터",
+12 -13
View File
@@ -4583,9 +4583,8 @@
"SkillFreshnessRow": {
"statusUpdateAvailable": "有更新",
"statusCantUpdate": "无法更新",
"cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 npx skills update 命令不会更改它。",
"cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 更新命令不会更改它。",
"chipCurrent": "最新",
"chipUnrecognized": "无法识别",
"chipInaccessible": "无法访问",
"chipDuplicate": "重复",
"chipExternalLink": "外部链接",
@@ -4594,26 +4593,20 @@
"chipInRepo": "在仓库中",
"chipPluginCache": "插件缓存",
"tipCurrent": "这里的技能已是最新,更新不会更改它。",
"tipUnrecognized": "这里的技能内容与任何官方版本都不匹配,因此 Orca 无法安全更新它。请移除或替换此处的内容以允许更新。",
"tipInaccessible": "Orca 无法读取这里的技能(权限或文件错误),因此无法检查或更新它。",
"tipDuplicate": "此技能除主副本外还安装在这里,因此 npx skills update 命令无法访问它。请移除它以允许更新。",
"tipDuplicate": "此技能除主副本外还安装在这里,因此 更新命令无法访问它。请移除它以允许更新。",
"tipExternalLink": "这是一个指向 Orca 技能文件夹之外的快捷方式;更新不会跟随它。",
"tipBrokenLink": "这是一个指向已不存在内容的快捷方式,可以安全删除。",
"tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。",
"tipInRepo": "这里的技能位于某个项目内,而非全局技能;Orca 只更新全局技能。",
"tipPluginCache": "这里的技能由插件管理;请改为更新插件。",
"skippedReasonUnrecognized": "此处的副本与官方版本不一致 — 它可能被修改过,或是同名但内容不同的技能。Orca 将其排除在更新之外,以免覆盖它。如果您希望 Orca 更新此技能,请删除它。",
"skippedReasonReadOnly": "此副本位于只读位置,因此 Orca 未将其纳入更新。请更改其权限,让 Orca 更新它。",
"skippedReasonInaccessible": "Orca 无法读取此副本,因此未将该技能纳入更新。",
"skippedReasonInRepo": "这是项目技能而非全局技能 — Orca 只更新您的全局技能,因此将其排除在更新之外。",
"skippedReasonPluginCache": "此技能由插件管理,因此 Orca 未将其纳入更新 — 请改为更新插件。",
"skippedReasonExternalLink": "此副本是指向 Orca 技能文件夹之外的快捷方式,因此 Orca 未将其纳入更新。",
"skippedReasonBrokenLink": "此副本是指向已不存在内容的快捷方式,因此 Orca 将其排除 — 您可以放心删除。",
"skippedReasonDuplicate": "这是独立的副本,因此更新不会触及它 — 该命令只刷新主副本。请删除此副本并重新安装技能,使此位置跟随主副本。",
"skippedReasonNewer": "此副本的版本比当前 Orca 版本内置的更新,因此 Orca 未动它,以免回退。更新 Orca 后两者将恢复一致。",
"skippedReasonStaleRecord": "技能更新器没有此副本的有效记录,因此它报告技能已是最新且不做任何更改。请重新安装,使记录恢复一致:{{value0}}",
"chipNewer": "Newer",
"tipNewer": "此副本的版本比当前 Orca 版本内置的更新。"
"skippedReasonDuplicate": "这是独立的副本,因此更新不会触及它 — 该命令只刷新主副本。请删除此副本并重新安装技能,使此位置跟随主副本。"
},
"SkillFreshnessUpdateDialog": {
"title": "更新技能",
@@ -4654,7 +4647,9 @@
"scanOutsideRoot": "此插件路径指向插件缓存之外,Orca 已将其跳过。",
"scanIoErrorWithCode": "Orca 无法读取此插件路径({{value0}})。",
"scanIoError": "Orca 无法读取此插件路径。",
"scanIssueLimit": "被跳过的插件文件夹过多,Orca 无法逐一列出。"
"scanIssueLimit": "被跳过的插件文件夹过多,Orca 无法逐一列出。",
"unselectedForeignOne": "有 1 个非 Orca 安装的技能未选中。",
"unselectedForeignMany": "有 {{value0}} 个非 Orca 安装的技能未选中。"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "有可用更新",
@@ -4669,8 +4664,12 @@
"stillOutdated": "更新运行后仍为过期状态。"
},
"SkillUpdateRow": {
"oneLocation": "1 location",
"manyLocations": "{{value0}} locations"
"oneLocation": "1 个位置",
"manyLocations": "{{value0}} 个位置",
"notFromOrca": "非 Orca 安装",
"notFromOrcaSourceTip": "安装自 {{value0}}。选中后会替换为 Orca 的版本。",
"notFromOrcaNoRecordTip": "此副本没有 Orca 安装记录。选中后会替换为 Orca 的版本。",
"includeSkill": "包含 {{value0}}"
},
"host": {
"local": "此机器",
@@ -1,22 +1,48 @@
import {
buildAgentFeatureSkillInstallCommand as buildPinnedSkillInstallCommand,
COMPUTER_USE_SKILL_NAME,
EPHEMERAL_VMS_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCA_LINEAR_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../../shared/agent-feature-install-commands'
export {
buildAgentFeatureSkillInstallCommand,
buildAgentFeatureSkillUpdateCommand,
COMPUTER_USE_SKILL_INSTALL_COMMAND,
COMPUTER_USE_SKILL_NAME,
COMPUTER_USE_SKILL_UPDATE_COMMAND,
EPHEMERAL_VMS_SKILL_INSTALL_COMMAND,
EPHEMERAL_VMS_SKILL_NAME,
EPHEMERAL_VMS_SKILL_UPDATE_COMMAND,
LINEAR_AGENT_SKILL_NAMES,
LINEAR_TICKETS_SKILL_NAME,
LINEAR_TICKETS_SKILL_UPDATE_COMMAND,
ORCA_LINEAR_SKILL_INSTALL_COMMAND,
ORCA_LINEAR_SKILL_NAME,
ORCA_LINEAR_SKILL_UPDATE_COMMAND,
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND,
ORCHESTRATION_SKILL_NAME,
ORCHESTRATION_SKILL_UPDATE_COMMAND
ORCA_LINEAR_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../../shared/agent-feature-install-commands'
/** The interactive install, pinned to this build; also the update, since `add` re-copies. */
export function buildAgentFeatureSkillInstallCommand(skillNames: readonly string[]): string {
return buildPinnedSkillInstallCommand(skillNames, { sourceCommit: ORCA_BUILD_COMMIT })
}
export const ORCA_CLI_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_CLI_SKILL_NAME
])
export const COMPUTER_USE_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
COMPUTER_USE_SKILL_NAME
])
export const ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCHESTRATION_SKILL_NAME
])
export const EPHEMERAL_VMS_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
EPHEMERAL_VMS_SKILL_NAME
])
export const ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
])
export const ORCA_LINEAR_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_LINEAR_SKILL_NAME
])
@@ -42,12 +42,8 @@ function placement(name: string, status: SkillFreshnessStatus): SkillFreshnessIn
physicalIdentity: name,
topology: 'canonical-copy',
status,
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: status === 'current' ? 'current' : 'other',
lockSource: 'stablyai/orca',
errorCategory: null
}
}
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import type { DiscoveredSkill } from '../../../shared/skills'
import {
LINEAR_TICKETS_SKILL_UPDATE_COMMAND,
ORCA_LINEAR_SKILL_UPDATE_COMMAND
buildAgentFeatureSkillInstallCommand,
ORCA_LINEAR_SKILL_INSTALL_COMMAND
} from './agent-feature-install-commands'
import { getLinearAgentSkillUpdateTarget } from './linear-agent-skill-update-command'
@@ -26,24 +26,24 @@ describe('getLinearAgentSkillUpdateTarget', () => {
it('targets the canonical skill for missing, canonical, and dual-name installs', () => {
expect(getLinearAgentSkillUpdateTarget([], false)).toEqual({
skillName: 'orca-linear',
command: ORCA_LINEAR_SKILL_UPDATE_COMMAND
command: ORCA_LINEAR_SKILL_INSTALL_COMMAND
})
expect(getLinearAgentSkillUpdateTarget([skill('orca-linear')], true)).toEqual({
skillName: 'orca-linear',
command: ORCA_LINEAR_SKILL_UPDATE_COMMAND
command: ORCA_LINEAR_SKILL_INSTALL_COMMAND
})
expect(
getLinearAgentSkillUpdateTarget([skill('orca-linear'), skill('linear-tickets')], true)
).toEqual({
skillName: 'orca-linear',
command: ORCA_LINEAR_SKILL_UPDATE_COMMAND
command: ORCA_LINEAR_SKILL_INSTALL_COMMAND
})
})
it('keeps the update and freshness identity on a legacy-only install', () => {
expect(getLinearAgentSkillUpdateTarget([skill('linear-tickets')], true)).toEqual({
skillName: 'linear-tickets',
command: LINEAR_TICKETS_SKILL_UPDATE_COMMAND
command: buildAgentFeatureSkillInstallCommand(['linear-tickets'])
})
})
})
@@ -4,10 +4,9 @@ import {
hasInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import {
buildAgentFeatureSkillInstallCommand,
LINEAR_TICKETS_SKILL_NAME,
LINEAR_TICKETS_SKILL_UPDATE_COMMAND,
ORCA_LINEAR_SKILL_NAME,
ORCA_LINEAR_SKILL_UPDATE_COMMAND
ORCA_LINEAR_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
export type LinearAgentSkillUpdateTarget = {
@@ -27,9 +26,11 @@ export function getLinearAgentSkillUpdateTarget(
const legacySkillInstalled = hasInstalledAgentSkill(skills, LINEAR_TICKETS_SKILL_NAME, {
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
return !installed || canonicalSkillInstalled || !legacySkillInstalled
? { skillName: ORCA_LINEAR_SKILL_NAME, command: ORCA_LINEAR_SKILL_UPDATE_COMMAND }
: { skillName: LINEAR_TICKETS_SKILL_NAME, command: LINEAR_TICKETS_SKILL_UPDATE_COMMAND }
const skillName =
!installed || canonicalSkillInstalled || !legacySkillInstalled
? ORCA_LINEAR_SKILL_NAME
: LINEAR_TICKETS_SKILL_NAME
return { skillName, command: buildAgentFeatureSkillInstallCommand([skillName]) }
}
export function getLinearAgentSkillUpdateCommand(
@@ -1,4 +0,0 @@
export {
ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCHESTRATION_SKILL_UPDATE_COMMAND
} from '../../../shared/agent-feature-install-commands'
@@ -37,18 +37,14 @@ function placement(status: SkillFreshnessStatus, index = 0): SkillFreshnessInsta
physicalIdentity: `physical-${index}`,
topology: 'canonical-copy',
status,
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: status === 'current' ? 'current' : 'other',
lockSource: 'stablyai/orca',
errorCategory: null
}
}
function pluginCachePlacement(
status: SkillFreshnessStatus = 'unrecognized'
status: SkillFreshnessStatus = 'outdated'
): SkillFreshnessInstallation {
return {
...placement(status, 9),
@@ -57,7 +53,7 @@ function pluginCachePlacement(
}
}
function repoPlacement(status: SkillFreshnessStatus = 'unrecognized'): SkillFreshnessInstallation {
function repoPlacement(status: SkillFreshnessStatus = 'outdated'): SkillFreshnessInstallation {
return {
...placement(status, 7),
rootId: 'repo-work',
@@ -84,6 +80,19 @@ describe('getSkillFreshnessDisplayStatus', () => {
).toBe('update-available')
})
it.each([null, 'acme/agent-skills'])(
'reports presence only for an update to a copy Orca did not install (%s)',
(lockSource) => {
// Why: such a copy is offered unselected in the dialog, never advertised.
expect(
getSkillFreshnessDisplayStatus(
inventory([{ ...placement('outdated'), lockSource }], [SKILL_NAME]),
SKILL_NAME
)
).toBe('installed')
}
)
it('shows up to date only when every discovered placement is current', () => {
expect(
getSkillFreshnessDisplayStatus(
@@ -104,8 +113,8 @@ describe('getSkillFreshnessDisplayStatus', () => {
it.each([
[
'when any placement is unrecognized',
inventory([placement('current'), placement('unrecognized', 1)])
'when a placement the update cannot reach is outdated',
inventory([placement('current'), { ...placement('outdated', 1), topology: 'external-link' }])
],
['when a placement is inaccessible', inventory([placement('inaccessible')])],
['when an outdated placement is not eligible', inventory([placement('outdated')])]
@@ -161,39 +170,21 @@ describe('getSkillFreshnessDisplayStatus', () => {
)
})
it('shows recognized-newer content as up to date instead of blaming the user', () => {
// Why: 'newer-known' is official content ahead of this build — the updater's own
// install or a newer release's bytes. Amber here sent users on a remove/reinstall
// loop that lands the same newer content (#11220's scan half).
const value = inventory([placement('newer-known')])
expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('up-to-date')
expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false)
})
it('still reports drift beside a newer-known copy', () => {
expect(
getSkillFreshnessDisplayStatus(
inventory([placement('newer-known'), placement('unrecognized', 1)]),
SKILL_NAME
)
).toBe('needs-attention')
})
it('still reports drift in our own copy when a plugin-managed one sits alongside', () => {
expect(
getSkillFreshnessDisplayStatus(
inventory([placement('unrecognized'), pluginCachePlacement()]),
inventory([placement('inaccessible'), pluginCachePlacement()]),
SKILL_NAME
)
).toBe('needs-attention')
})
it('reports attention when a plugin-cache copy is inaccessible', () => {
it('reports presence only for a plugin-cache copy, readable or not', () => {
// Why: Orca never writes there and cannot tell an old release from another vendor's skill.
const value = inventory([pluginCachePlacement('inaccessible')])
expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('needs-attention')
expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(true)
expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed')
expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false)
})
})
@@ -225,16 +216,15 @@ describe('hasSkillCopyNeedingAttention', () => {
// copy inside a work directory. Orca only updates global skills, so the project copy
// must not turn the badge amber over drift it has no way to fix.
it('reports up to date for a current global copy beside a drifted project copy', () => {
const value = inventory([placement('current'), repoPlacement('unrecognized')])
const value = inventory([placement('current'), repoPlacement('outdated')])
expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('up-to-date')
expect(hasSkillCopyNeedingAttention(value, SKILL_NAME)).toBe(false)
})
// Why by scope and not by status: an outdated or unreadable project copy is just as
// far outside the global updater's reach as an unrecognized one, so pinning only the
// reported status would leave the same bug reachable through a different one.
it.each<[SkillFreshnessStatus]>([['unrecognized'], ['outdated'], ['inaccessible']])(
// Why by scope and not by status: an outdated or unreadable project copy is equally
// far outside the global updater's reach.
it.each<[SkillFreshnessStatus]>([['outdated'], ['inaccessible']])(
'ignores a %s project copy when judging global freshness',
(status) => {
const value = inventory([placement('current'), repoPlacement(status)])
@@ -1,4 +1,5 @@
import {
isOrcaSkillLockSource,
isSkillCopyNeedingAttention,
isSkillScanIssueNeedingAttention,
skillPlacementParticipatesInGlobalFreshness,
@@ -16,7 +17,14 @@ export function getSkillFreshnessDisplayStatus(
skillName: string
): SkillFreshnessDisplayStatus {
if (inventory?.eligibleUpdateNames.includes(skillName)) {
return 'update-available'
// Why: a same-named copy Orca did not install is offered unselected in the dialog,
// but never advertised; "up to date" would be false, so report presence only.
return inventory.installations.some(
(installation) =>
installation.name === skillName && isOrcaSkillLockSource(installation.lockSource)
)
? 'update-available'
: 'installed'
}
let hasPlacement = false
let hasBlockedCopy = false
@@ -24,22 +32,14 @@ export function getSkillFreshnessDisplayStatus(
if (installation.name !== skillName) {
continue
}
// Why: a project-owned copy is outside the global updater's reach, so it can neither
// stand in as evidence this skill is installed globally nor make the badge amber over
// drift Orca has no way to fix. Skipped before `hasPlacement` so a repo-only skill
// reports presence, not a freshness claim about a copy Orca does not manage.
// Why: a project's or plugin's own copy is outside the global updater's reach, so it
// can neither stand in as evidence this skill is installed globally nor make the badge
// amber over drift Orca has no way to fix.
if (!skillPlacementParticipatesInGlobalFreshness(installation)) {
continue
}
hasPlacement = true
// Why: 'newer-known' is recognized official content ahead of this build — the
// updater's own install or a newer release's bytes. There is nothing to fix and
// nothing to update to, so amber would send the user chasing a phantom edit.
if (
installation.status !== 'current' &&
installation.status !== 'newer-known' &&
!(installation.status === 'unrecognized' && installation.topology === 'plugin-cache')
) {
if (installation.status !== 'current') {
hasBlockedCopy = true
}
}
@@ -2,39 +2,45 @@ import { describe, expect, it } from 'vitest'
import {
buildAgentFeatureSkillInstallArgs,
buildAgentFeatureSkillInstallCommand,
ORCA_CLI_SKILL_INSTALL_COMMAND,
buildAgentFeatureSkillUpdateArgs,
buildAgentFeatureSkillUpdateCommand,
COMPUTER_USE_SKILL_UPDATE_COMMAND,
EPHEMERAL_VMS_SKILL_UPDATE_COMMAND,
LINEAR_TICKETS_SKILL_UPDATE_COMMAND,
ORCA_LINEAR_SKILL_UPDATE_COMMAND,
ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_UPDATE_COMMAND,
ORCHESTRATION_SKILL_UPDATE_COMMAND
SKILLS_CLI_VERSION
} from './agent-feature-install-commands'
const sourceCommit = '0123456789abcdef0123456789abcdef01234567'
const source = `https://github.com/stablyai/orca/tree/${sourceCommit}`
describe('agent feature skill commands', () => {
it('pins the skills CLI and the source to the build commit', () => {
expect(buildAgentFeatureSkillInstallArgs(['orca-cli'], { sourceCommit }).slice(0, 3)).toEqual([
`skills@${SKILLS_CLI_VERSION}`,
'add',
source
])
})
it('builds a global install command by default', () => {
expect(buildAgentFeatureSkillInstallCommand(['orca-cli'])).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli --global'
expect(buildAgentFeatureSkillInstallCommand(['orca-cli'], { sourceCommit })).toBe(
`npx skills@${SKILLS_CLI_VERSION} add ${source} --skill orca-cli --global`
)
})
it('drops --global when installing locally', () => {
expect(buildAgentFeatureSkillInstallCommand(['orca-cli'], { global: false })).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli'
)
expect(
buildAgentFeatureSkillInstallCommand(['orca-cli'], { sourceCommit, global: false })
).toBe(`npx skills@${SKILLS_CLI_VERSION} add ${source} --skill orca-cli`)
})
it('repeats --skill per name for multi-skill installs', () => {
expect(buildAgentFeatureSkillInstallCommand(['orca-cli', 'orchestration'])).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global'
expect(
buildAgentFeatureSkillInstallCommand(['orca-cli', 'orchestration'], { sourceCommit })
).toBe(
`npx skills@${SKILLS_CLI_VERSION} add ${source} --skill orca-cli --skill orchestration --global`
)
expect(buildAgentFeatureSkillInstallArgs(['orca-cli', 'orchestration'])).toEqual([
'skills',
expect(
buildAgentFeatureSkillInstallArgs(['orca-cli', 'orchestration'], { sourceCommit })
).toEqual([
`skills@${SKILLS_CLI_VERSION}`,
'add',
'https://github.com/stablyai/orca',
source,
'--skill',
'orca-cli',
'--skill',
@@ -43,21 +49,24 @@ describe('agent feature skill commands', () => {
])
})
it('rejects an empty skill list', () => {
expect(() => buildAgentFeatureSkillInstallArgs([], { sourceCommit })).toThrow(
'At least one skill name is required.'
)
})
it('keeps the copyable Settings commands interactive by default', () => {
// Why: -y skips the agent picker. A human pasting from Settings should still
// get it; only an unattended spawn opts in.
expect(buildAgentFeatureSkillInstallCommand(['orca-cli'])).not.toContain('-y')
expect(buildAgentFeatureSkillUpdateCommand('orca-cli')).not.toContain('-y')
expect(ORCA_CLI_SKILL_INSTALL_COMMAND).not.toContain('-y')
expect(ORCA_CLI_SKILL_UPDATE_COMMAND).not.toContain('-y')
expect(buildAgentFeatureSkillInstallCommand(['orca-cli'], { sourceCommit })).not.toContain('-y')
})
it('refuses to skip prompts without an install target', () => {
// Why: -y with no --agent is the one combination that makes `skills add`
// install into every agent it knows (~75). No caller may express it.
expect(() => buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true })).toThrow(
'An install target is required when skipping prompts.'
)
expect(() =>
buildAgentFeatureSkillInstallCommand(['orca-cli'], { sourceCommit, yes: true })
).toThrow('An install target is required when skipping prompts.')
})
it('refuses a target the skills CLI would drop', () => {
@@ -65,68 +74,30 @@ describe('agent feature skill commands', () => {
// drops a `-`-leading --agent value, which empties its target list and
// installs into every agent it knows.
expect(() =>
buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true, agents: ['-y'] })
buildAgentFeatureSkillInstallCommand(['orca-cli'], {
sourceCommit,
yes: true,
agents: ['-y']
})
).toThrow('"-y" is not a usable install target.')
expect(() =>
buildAgentFeatureSkillInstallArgs(['orca-cli'], { yes: true, agents: ['universal', 'a b'] })
buildAgentFeatureSkillInstallArgs(['orca-cli'], {
sourceCommit,
yes: true,
agents: ['universal', 'a b']
})
).toThrow('"a b" is not a usable install target.')
})
it('appends -y and the targets for an unattended run', () => {
expect(
buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true, agents: ['universal'] })
buildAgentFeatureSkillInstallCommand(['orca-cli'], {
sourceCommit,
yes: true,
agents: ['universal']
})
).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli --global --agent universal -y'
)
expect(buildAgentFeatureSkillUpdateCommand(['orca-cli'], { global: false, yes: true })).toBe(
'npx skills update orca-cli --project -y'
)
expect(
buildAgentFeatureSkillInstallArgs(['orca-cli'], { yes: true, agents: ['universal'] }).at(-1)
).toBe('-y')
expect(buildAgentFeatureSkillUpdateArgs(['orca-cli'], { yes: true }).at(-1)).toBe('-y')
})
it('builds single-skill update commands', () => {
expect(buildAgentFeatureSkillUpdateCommand('orchestration')).toBe(
'npx skills update orchestration --global'
)
})
it('trims and rejects blank update skill names', () => {
expect(buildAgentFeatureSkillUpdateCommand(' orca-cli ')).toBe(
'npx skills update orca-cli --global'
)
expect(() => buildAgentFeatureSkillUpdateCommand(' ')).toThrow('A skill name is required.')
})
it('builds multi-skill update commands and selects project scope for --local', () => {
expect(buildAgentFeatureSkillUpdateCommand(['orca-cli', 'orchestration'])).toBe(
'npx skills update orca-cli orchestration --global'
)
expect(buildAgentFeatureSkillUpdateCommand(['orca-cli'], { global: false })).toBe(
'npx skills update orca-cli --project'
)
expect(buildAgentFeatureSkillUpdateArgs(['orca-cli'], { global: false })).toEqual([
'skills',
'update',
'orca-cli',
'--project'
])
expect(() => buildAgentFeatureSkillUpdateCommand([])).toThrow('A skill name is required.')
})
it('exports single-skill update constants without changing install bundles', () => {
expect(ORCA_CLI_SKILL_UPDATE_COMMAND).toBe('npx skills update orca-cli --global')
expect(COMPUTER_USE_SKILL_UPDATE_COMMAND).toBe('npx skills update computer-use --global')
expect(ORCHESTRATION_SKILL_UPDATE_COMMAND).toBe('npx skills update orchestration --global')
expect(EPHEMERAL_VMS_SKILL_UPDATE_COMMAND).toBe(
'npx skills update orca-per-workspace-env --global'
)
expect(ORCA_LINEAR_SKILL_UPDATE_COMMAND).toBe('npx skills update orca-linear --global')
expect(LINEAR_TICKETS_SKILL_UPDATE_COMMAND).toBe('npx skills update linear-tickets --global')
expect(ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND).toBe(
buildAgentFeatureSkillInstallCommand(['orca-cli', 'orchestration'])
`npx skills@${SKILLS_CLI_VERSION} add ${source} --skill orca-cli --global --agent universal -y`
)
})
})
+13 -73
View File
@@ -2,6 +2,10 @@ import { isUsableSkillsCliAgentKey } from './skills-cli-agent-keys'
export const ORCA_SKILLS_REPOSITORY_URL = 'https://github.com/stablyai/orca'
// Why: one skills CLI for every install rail. 1.5.x cannot fetch a commit, and a bare
// `npx skills` can reuse a stale cached copy or another binary (#18687).
export const SKILLS_CLI_VERSION = '1.7.2'
export const ORCA_CLI_SKILL_NAME = 'orca-cli'
export const COMPUTER_USE_SKILL_NAME = 'computer-use'
export const ORCHESTRATION_SKILL_NAME = 'orchestration'
@@ -14,6 +18,8 @@ export const LINEAR_AGENT_SKILL_NAMES = [ORCA_LINEAR_SKILL_NAME, LINEAR_TICKETS_
// pastes keeps its interactive prompts and the CLI's own agent detection. Only an
// unattended spawn, which nothing can answer, opts in.
export type AgentFeatureSkillCommandOptions = {
/** Full commit the running build was made from. */
sourceCommit: string
global?: boolean
yes?: boolean
agents?: readonly string[]
@@ -21,7 +27,7 @@ export type AgentFeatureSkillCommandOptions = {
export function buildAgentFeatureSkillInstallArgs(
skillNames: readonly string[],
options: AgentFeatureSkillCommandOptions = {}
options: AgentFeatureSkillCommandOptions
): string[] {
if (skillNames.length === 0) {
throw new Error('At least one skill name is required.')
@@ -42,9 +48,12 @@ export function buildAgentFeatureSkillInstallArgs(
// Why: one flag per name remains compatible with both single-value and variadic parsers.
const skillArgs = skillNames.flatMap((name) => ['--skill', name])
return [
'skills',
`skills@${SKILLS_CLI_VERSION}`,
'add',
ORCA_SKILLS_REPOSITORY_URL,
// Why: pinned to the build's own commit so the installed bytes are the ones its bundled
// manifest judges freshness against, on every channel (hourly builds have no tag in
// this repo). `add` always re-copies, so the same command is install and update.
`${ORCA_SKILLS_REPOSITORY_URL}/tree/${options.sourceCommit}`,
...skillArgs,
...(global ? ['--global'] : []),
// Why: an explicit --agent stops `skills add` calling its own detection, whose
@@ -59,76 +68,7 @@ export function buildAgentFeatureSkillInstallArgs(
export function buildAgentFeatureSkillInstallCommand(
skillNames: readonly string[],
options: AgentFeatureSkillCommandOptions = {}
options: AgentFeatureSkillCommandOptions
): string {
return `npx ${buildAgentFeatureSkillInstallArgs(skillNames, options).join(' ')}`
}
export function buildAgentFeatureSkillUpdateArgs(
skillNames: string | readonly string[],
options: AgentFeatureSkillCommandOptions = {}
): string[] {
const rawNames = typeof skillNames === 'string' ? [skillNames] : skillNames
const names = rawNames.map((name) => name.trim()).filter((name) => name.length > 0)
if (names.length === 0) {
throw new Error('A skill name is required.')
}
const global = options.global ?? true
return [
'skills',
'update',
...names,
global ? '--global' : '--project',
...(options.yes ? ['-y'] : [])
]
}
export function buildAgentFeatureSkillUpdateCommand(
skillNames: string | readonly string[],
options: AgentFeatureSkillCommandOptions = {}
): string {
return `npx ${buildAgentFeatureSkillUpdateArgs(skillNames, options).join(' ')}`
}
export const ORCA_CLI_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_CLI_SKILL_NAME
])
export const ORCA_CLI_SKILL_UPDATE_COMMAND =
buildAgentFeatureSkillUpdateCommand(ORCA_CLI_SKILL_NAME)
export const COMPUTER_USE_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
COMPUTER_USE_SKILL_NAME
])
export const COMPUTER_USE_SKILL_UPDATE_COMMAND =
buildAgentFeatureSkillUpdateCommand(COMPUTER_USE_SKILL_NAME)
export const ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCHESTRATION_SKILL_NAME
])
export const ORCHESTRATION_SKILL_UPDATE_COMMAND =
buildAgentFeatureSkillUpdateCommand(ORCHESTRATION_SKILL_NAME)
export const EPHEMERAL_VMS_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
EPHEMERAL_VMS_SKILL_NAME
])
export const EPHEMERAL_VMS_SKILL_UPDATE_COMMAND =
buildAgentFeatureSkillUpdateCommand(EPHEMERAL_VMS_SKILL_NAME)
export const ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
])
export const ORCA_LINEAR_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_LINEAR_SKILL_NAME
])
export const ORCA_LINEAR_SKILL_UPDATE_COMMAND =
buildAgentFeatureSkillUpdateCommand(ORCA_LINEAR_SKILL_NAME)
export const LINEAR_TICKETS_SKILL_UPDATE_COMMAND =
buildAgentFeatureSkillUpdateCommand(LINEAR_TICKETS_SKILL_NAME)
+35 -77
View File
@@ -10,41 +10,21 @@ export type SkillBundleFileIdentity = {
identitySha256: string
}
export type SkillKnownSnapshot = {
releaseRevision: number
export type SkillCurrentBundleEntry = {
name: string
sourcePath: string
packageDigest: string
gitTreeSha: string
files: SkillBundleFileIdentity[]
}
export type SkillCurrentBundleEntry = SkillKnownSnapshot & {
name: string
sourcePath: string
}
// Why: schema 2 removed the stamped app version so the committed artifact is a
// pure function of skills/ content; the running build supplies its own version.
// Why: schema 3 dropped the release revision and git tree identity with the
// snapshot registry; freshness compares against this build's bytes only.
export type SkillBundleManifest = {
schemaVersion: 2
schemaVersion: 3
skills: SkillCurrentBundleEntry[]
}
export type SkillSnapshotRegistry = {
schemaVersion: 1
skills: Record<string, SkillKnownSnapshot[]>
}
export type SkillReleaseMapping = {
schemaVersion: 1
releases: { appVersion: string; skills: Record<string, number> }[]
}
export type SkillFreshnessStatus =
| 'current'
| 'outdated'
| 'newer-known'
| 'unrecognized'
| 'inaccessible'
export type SkillFreshnessStatus = 'current' | 'outdated' | 'inaccessible'
export type SkillInstallationTopology =
| 'canonical-copy'
@@ -57,7 +37,7 @@ export type SkillInstallationTopology =
| 'plugin-cache'
// Why: eligibility and the explanation copy must agree on which placements the
// validated npx rail can converge; a drifted copy would blame a phantom sibling.
// pinned install rail rewrites; a drifted copy would blame a phantom sibling.
export const SUPPORTED_GLOBAL_SKILL_TOPOLOGIES: ReadonlySet<SkillInstallationTopology> = new Set([
'canonical-copy',
'provider-alias'
@@ -75,47 +55,36 @@ export type SkillFreshnessInstallation = {
physicalIdentity: string | null
topology: SkillInstallationTopology
status: SkillFreshnessStatus
installedReleaseRevision: number | null
installedAppVersion: string | null
currentReleaseRevision: number
currentPackageDigest: string
currentAppVersion: string
observedPackageDigest: string | null
/** Git tree sha of the observed bytes; lets the post-run verdict match disk against the updater's lock. */
observedGitTreeSha?: string | null
/**
* The same hash over only the files the current bundle lists. Carried beside the
* whole-folder hash, not in place of it, so a folder holding an agent CLI's sidecar
* can still match the lock without blinding the check to an upstream revision that
* added a file. Absent from hosts older than this field.
*/
observedOfficialGitTreeSha?: string | null
/** The skills.sh global lock's `source` for this name; null when it has no entry. */
lockSource: string | null
errorCategory: string | null
}
// A scope whose contents belong to someone other than the user: a project's own checkout,
// or a plugin's bundled copy. Orca's global update never writes here, so the owner's
// content is not the user's drift — which is why ownership outranks byte status when
// labelling a location.
const OWNER_MANAGED_SKILL_SCOPES: ReadonlySet<SkillInstallationTopology> = new Set([
'repo-scope',
'plugin-cache'
])
const ORCA_SKILL_LOCK_SOURCE = 'stablyai/orca'
export function isOwnerManagedSkillScope(topology: SkillInstallationTopology): boolean {
return OWNER_MANAGED_SKILL_SCOPES.has(topology)
/** Whether the skills.sh lock says this name was installed from Orca's repository. */
export function isOrcaSkillLockSource(source: string | null): boolean {
if (!source) {
return false
}
const normalized = source
.trim()
.toLowerCase()
.replace(/^https:\/\/github\.com\//, '')
.replace(/\/+$/, '')
.replace(/\.git$/, '')
return normalized === ORCA_SKILL_LOCK_SOURCE
}
// Why: Orca's updater only ever passes --global, so a copy a project owns is not
// something Orca can act on — it has no remedy by design. Reporting it as global drift
// produced an amber badge no user action could clear, over a copy their own repo
// legitimately owns. Stated by scope rather than by byte status on purpose: an outdated
// or unreadable project copy is just as far outside the global updater's reach as an
// unrecognized one.
// Why: Orca's updater only ever passes --global, so a project's or a plugin's own copy is
// not something Orca can act on. Without a list of past releases, a differing plugin copy
// is indistinguishable from another vendor's same-named skill, so neither scope can be
// reported as the user's drift.
export function skillPlacementParticipatesInGlobalFreshness(
installation: SkillFreshnessInstallation
): boolean {
return installation.topology !== 'repo-scope'
return installation.topology !== 'repo-scope' && installation.topology !== 'plugin-cache'
}
/**
@@ -123,20 +92,13 @@ export function skillPlacementParticipatesInGlobalFreshness(
*
* Shared so the badge and the review dialog cannot disagree about what counts: the
* badge points at the dialog for the explanation, so a copy that turns the badge
* amber must also produce a row there. An out-of-date copy the command converges is
* ordinary work, not a problem; a plugin's own copy of a same-named skill is the vendor's
* business rather than the user's drift, and a project's own copy is outside the reach of
* the only update Orca runs.
* amber must also produce a row there. An out-of-date copy the command rewrites is
* ordinary work, not a problem.
*/
export function isSkillCopyNeedingAttention(installation: SkillFreshnessInstallation): boolean {
return (
skillPlacementParticipatesInGlobalFreshness(installation) &&
installation.status !== 'current' &&
// Why: 'newer-known' is recognized official content ahead of this build — the
// updater's own install or a newer release's bytes. There is nothing to fix and
// nothing to update to, so amber would send the user chasing a phantom edit.
installation.status !== 'newer-known' &&
!(installation.status === 'unrecognized' && installation.topology === 'plugin-cache') &&
!(
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) &&
installation.status === 'outdated'
@@ -199,6 +161,7 @@ export function isSkillScanIssueTruncatingScan(issue: SkillFreshnessScanIssue):
export type SkillFreshnessInventory = {
schemaVersion: 1
installations: SkillFreshnessInstallation[]
/** Outdated names the pinned install can safely rewrite, whoever installed them. */
eligibleUpdateNames: string[]
scanIssues: SkillFreshnessScanIssue[]
scannedAt: number
@@ -206,7 +169,7 @@ export type SkillFreshnessInventory = {
export function canonicalizeSkillUpdateNames(names: readonly string[]): string[] | null {
const canonicalNames = [...new Set(names)].sort((left, right) => left.localeCompare(right, 'en'))
// Why: names reach a shell in the terminal fallback. Official manifests use
// Why: names reach a shell in the copied fallback command. Official manifests use
// this restricted package-name grammar so no entry can introduce shell syntax.
if (canonicalNames.some((name) => !/^[a-z0-9][a-z0-9._-]*$/.test(name))) {
return null
@@ -214,14 +177,9 @@ export function canonicalizeSkillUpdateNames(names: readonly string[]): string[]
return canonicalNames.length > 0 ? canonicalNames : null
}
export function buildTargetedSkillUpdateCommand(names: readonly string[]): string | null {
const canonicalNames = canonicalizeSkillUpdateNames(names)
return canonicalNames ? `npx skills update ${canonicalNames.join(' ')} --global` : null
}
// Why: `skills update` has no --json (that flag only exists on `list`), so the
// run reports one indeterminate phase. Per-skill outcomes come from re-scanning
// the inventory after exit, never from parsing stdout.
// Why: `skills add` has no --json, so the run reports one indeterminate phase.
// Per-skill outcomes come from re-scanning the inventory after exit, never from
// parsing stdout.
export type SkillUpdateRun =
| { state: 'idle' }
// `stopping` covers the window between Stop and the process tree actually
+26
View File
@@ -0,0 +1,26 @@
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
import { SKILLS_CLI_UNIVERSAL_AGENT_KEY, toSkillsCliAgentKeys } from './skills-cli-agent-keys'
import {
getTuiAgentDetectionProbeCommands,
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
resolveDetectedTuiAgentIds
} from './tui-agent-detection-commands'
/** Agents Orca can see on this host, as `skills --agent` keys; empty when it sees none. */
export function detectSkillsCliAgentKeys(): string[] {
const runtime = process.platform
const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime)
const detected = resolveDetectedTuiAgentIds(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
detectCommandsInInstallDirs(probes),
runtime
)
return detected.length === 0 ? [] : toSkillsCliAgentKeys(detected)
}
// Why: an update rewrites a skill the shared folder already holds, so a host with no
// detected agent still has a target, and never the CLI's all-agents default.
export function detectSkillsCliUpdateAgentKeys(): string[] {
const detected = detectSkillsCliAgentKeys()
return detected.length > 0 ? detected : [SKILLS_CLI_UNIVERSAL_AGENT_KEY]
}
+3
View File
@@ -20,3 +20,6 @@ declare const ORCA_POSTHOG_WRITE_KEY: string | null
// point a packaged build at a staging server without re-running the
// release pipeline.
declare const ORCA_DIAGNOSTICS_TOKEN_URL: string | null
// Full commit the build was made from (`git rev-parse HEAD`); official skill installs pin to it.
declare const ORCA_BUILD_COMMIT: string

Some files were not shown because too many files have changed in this diff Show More