From 006f13997f9118741180262b80f9c93677c80631 Mon Sep 17 00:00:00 2001 From: dd3ok Date: Sat, 1 Aug 2026 01:42:11 +0900 Subject: [PATCH 1/2] feat: add managed skill lifecycle --- .claude-plugin/marketplace.json | 4 +- .codex-plugin/plugin.json | 13 +- CHANGELOG.md | 13 + README.ko.md | 49 +- README.md | 49 +- SECURITY.md | 20 +- adapters/antigravity/cli/plugin.json | 2 +- adapters/antigravity/cli/scripts/stash.mjs | 2262 +++++++++++++++-- .../cli/skills/references/CLI-CONTRACT.md | 38 + .../cli/skills/references/CONFIGURATION.md | 21 +- adapters/antigravity/cli/skills/stash.md | 84 +- adapters/antigravity/ide/plugin.json | 2 +- .../antigravity/ide/skills/stash/SKILL.md | 84 +- .../skills/stash/references/CLI-CONTRACT.md | 38 + .../skills/stash/references/CONFIGURATION.md | 21 +- .../ide/skills/stash/scripts/stash.mjs | 2262 +++++++++++++++-- .../claude-code/.claude-plugin/plugin.json | 4 +- adapters/claude-code/skills/stash/SKILL.md | 84 +- .../skills/stash/references/CLI-CONTRACT.md | 38 + .../skills/stash/references/CONFIGURATION.md | 21 +- .../skills/stash/scripts/stash.mjs | 2262 +++++++++++++++-- adapters/codex/.codex-plugin/plugin.json | 13 +- adapters/codex/skills/stash/SKILL.md | 84 +- .../codex/skills/stash/agents/openai.yaml | 4 +- .../skills/stash/references/CLI-CONTRACT.md | 38 + .../skills/stash/references/CONFIGURATION.md | 21 +- adapters/codex/skills/stash/scripts/stash.mjs | 2262 +++++++++++++++-- docs/alternatives.md | 14 +- docs/architecture.md | 82 +- docs/installation.md | 26 +- docs/maintenance.md | 34 +- docs/vendor-support.md | 35 +- package-lock.json | 4 +- package.json | 4 +- scripts/build-adapters.mjs | 6 +- skills/stash/SKILL.md | 84 +- skills/stash/agents/openai.yaml | 4 +- skills/stash/references/CLI-CONTRACT.md | 38 + skills/stash/references/CONFIGURATION.md | 21 +- skills/stash/scripts/stash.mjs | 2262 +++++++++++++++-- src/cli.ts | 164 ++ src/index.ts | 17 + src/internal/configuration.ts | 142 +- src/internal/managed-projection.ts | 281 ++ src/internal/search.ts | 6 + src/internal/util.ts | 28 + src/stash-catalog.ts | 23 +- src/stash-lifecycle.ts | 1622 ++++++++++++ src/types.ts | 146 ++ tests-dist/cli.test.mjs | 78 + tests/stash-lifecycle.test.ts | 468 ++++ 51 files changed, 14140 insertions(+), 1242 deletions(-) create mode 100644 src/internal/managed-projection.ts create mode 100644 src/stash-lifecycle.ts create mode 100644 tests/stash-lifecycle.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bc474f9..ffc390d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,8 +8,8 @@ { "name": "stash", "source": "./adapters/claude-code", - "description": "Search and load local Agent Skills from a separate SKILL.md library on explicit request.", - "version": "0.1.1", + "description": "Search read-only Agent Skills catalogs and manage an explicit local inactive store.", + "version": "0.2.0", "license": "MIT", "strict": true } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b405b43..a71aa68 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "stash", - "version": "0.1.1", - "description": "One explicit doorway to a local Agent Skills library—search by name or task and load only the selected SKILL.md.", + "version": "0.2.0", + "description": "Search read-only Agent Skills catalogs and explicitly manage a local inactive standalone skill store.", "author": { "name": "dd3ok", "url": "https://github.com/dd3ok" @@ -24,18 +24,19 @@ "skills": "./skills/", "interface": { "displayName": "Stash", - "shortDescription": "Open local Agent Skills only when requested.", - "longDescription": "Keep a large local Agent Skills library behind one explicit entry point. Search by exact name or task, then load only the selected SKILL.md instructions and required resources.", + "shortDescription": "Search and manage inactive local Agent Skills.", + "longDescription": "Keep a large local Agent Skills library behind one explicit entry point. Search external read-only catalogs, import standalone skills into an inactive managed store, and deploy only explicitly selected copies.", "developerName": "dd3ok", "category": "Productivity", "capabilities": [ "Local skill discovery", "Progressive disclosure", - "Read-only" + "Managed inactive skills" ], "defaultPrompt": [ "Use $stash design-system to load that exact stored skill.", - "Use $stash to find every stored skill relevant to this task." + "Use $stash to find every stored skill relevant to this task.", + "Use $stash to install this local skill as inactive." ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1642352..b89563d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Add a separate `StashLifecycle` Module with a local managed inactive store. +- Add explicit local `install`/`archive`/`activate`/`deactivate`/`status` + commands with provenance, tree hashes, tracked deployments, and drift guards. +- Add stable skill/deployment identities, explicit ownership and target records, + orthogonal status fields, archive journals, guarded dead-owner lock recovery, + and fail-closed malformed lock handling. +- Fold hash-matching catalog sources and Stash-owned deployments into the + managed canonical search result while preserving scoped lookup and raw reads. +- Reject workspace lifecycle and Antigravity CLI's flat standalone skill shapes. +- Keep external catalogs, plugins, and vendor settings outside lifecycle write + authority; reject links, overwrites, detached removals, and remote sources. +- Make a fresh Stash installation useful without catalog configuration by + auto-discovering its managed store after the first import. - Unify the public brand, package, repository, and marketplace identifiers as Stash. - Reject partial-word compact-name matches that promoted unrelated short queries. - Restore material routing for dense three-term descriptions at a regression-tested threshold. diff --git a/README.ko.md b/README.ko.md index 932dbf4..a93d7e3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -15,9 +15,10 @@ $stash API 문서 검토에 필요한 스킬을 모두 찾아줘 → 실질적으로 관련된 저장 스킬 전체 반환 ``` -Stash는 로컬에서 검색하고 원본 보관함을 읽기 전용으로 다루며, 선택한 -지침만 불러옵니다. 검색 중 스킬을 다운로드·설치·업데이트·실행하지 -않습니다. +Stash는 로컬에서 검색하고 모든 외부 보관함을 읽기 전용으로 다루며, 선택한 +지침만 불러옵니다. 별도의 관리형 보관소에는 명시적으로 가져온 독립 스킬을 +호스트에 배포하기 전까지 비활성 상태로 둘 수 있습니다. 검색 자체는 스킬을 +다운로드·설치·업데이트·실행하지 않습니다. ## 왜 필요한가요? @@ -46,7 +47,8 @@ Stash는 많은 로컬 스킬을 제품에 하나씩 등록하지 않고 이름, ## 동작 방식 1. 평소 사용하는 스킬은 제품의 기본 스킬 폴더에 둡니다. -2. 가끔 사용하는 스킬은 Stash에 설정한 별도 폴더에 둡니다. +2. 가끔 사용하는 스킬은 Stash 관리형 보관소로 가져오거나 기존 읽기 전용 + 보관함을 설정합니다. 3. 사용자가 Stash를 명시적으로 호출합니다. 4. 정확한 이름을 확인하거나 로컬 어휘 검색을 실행합니다. 5. 선택한 `SKILL.md`와 필요한 참고 파일만 읽습니다. @@ -92,6 +94,38 @@ defaults: `STASH_CONFIG`에 설정 파일 경로를 지정합니다. 한 번만 사용할 때는 `--root <보관함-경로>`를 사용할 수 있습니다. +### 관리형 비활성 스킬 + +관리형 보관소는 별도 catalog 설정 없이 바로 쓸 수 있습니다. `install`은 +로컬 스킬 디렉터리를 복사하고 원본은 그대로 둡니다. + +```bash +stash install D:/downloads/rare-skill +stash archive old-skill --host codex +stash status rare-skill +stash activate rare-skill --host codex +stash deactivate rare-skill --host codex +``` + +`install`, `import`, `add`는 같은 명령입니다. `archive`는 파괴적 변형으로, +명시적으로 선택한 독립 스킬을 검증해 보관한 뒤 호스트 검색 경로의 원본을 +제거합니다. 플러그인에 포함된 스킬은 관리하지 않습니다. `activate` 결과는 +`deployed`로 기록하며 호스트의 별도 활성·비활성 설정까지 켜졌다고 단정하지 +않습니다. + +CLI는 로컬 디렉터리만 가져옵니다. 사용자가 Stash 스킬에 저장소의 특정 +스킬을 명시적으로 가져오라고 요청하면, 에이전트가 호스트 검색 경로 밖에 +고정 revision을 임시로 준비하고 검토한 뒤 그 로컬 경로를 `install`에 +전달할 수 있습니다. 설정된 catalog 안의 스킬도 원본을 변경하지 않고 +설치할 수 있습니다. 같은 원본이나 Stash 소유 배포본이 catalog 검색에도 +나오면 해시가 일치할 때 관리형 canonical 결과의 관련 사본으로 접습니다. +변경되었거나 연관되지 않은 사본은 별도 결과와 경고로 남깁니다. + +첫 생명주기 버전은 로컬 전용입니다. 원격 Git, 심볼릭 링크 배포, 덮어쓰기, +플러그인 변경, 벤더 설정 변경, workspace 생명주기 대상은 지원하지 않습니다. +Antigravity CLI의 독립 스킬 형식은 문서상 디렉터리가 아닌 단일 Markdown +파일이므로 생명주기 명령의 대상으로 사용할 수 없습니다. + ## 제품별 지원 | 제품 | 명시 호출 | 자동 선택 | @@ -106,12 +140,13 @@ Antigravity 어댑터는 생성되지만, 지원을 공개하기 전에 대상 ` ## 범위 -- 원본 보관함은 읽기 전용으로 유지합니다. +- 외부 원본 보관함은 읽기 전용입니다. 쓰기는 Stash 관리형 보관소와 + 명시적으로 선택한 독립 스킬 생명주기 대상에만 허용합니다. - 네트워크, embedding 모델, vector database, 별도 LLM 라우터를 사용하지 않습니다. - 스킬을 읽으면서 포함된 스크립트를 실행하지 않습니다. -- 설치기, 업데이트 도구, 마켓플레이스, 권한 시스템, 샌드박스 또는 보안 - 검사기가 아닙니다. +- 마켓플레이스, 원격 업데이트 도구, 권한 시스템, 샌드박스 또는 보안 + 검사기가 아닙니다. 플러그인 생명주기는 각 호스트가 관리합니다. ## 문서 diff --git a/README.md b/README.md index 4861fba..5c1751b 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ $stash find every skill for reviewing API documentation → return every materially relevant stored skill ``` -Stash searches locally, treats source libraries as read-only, and loads only -the selected instructions. It does not download, install, update, or execute -skills while searching. +Stash searches locally, treats every external source library as read-only, and +loads only the selected instructions. Its separate managed store can also keep +an explicitly imported standalone skill inactive until you deploy it to a host. +Search never downloads, installs, updates, or executes a skill. ## Why @@ -47,7 +48,8 @@ host discovery ## How it works 1. Keep everyday skills in the host's standard skill directory. -2. Keep occasional skills in a separate folder configured in Stash. +2. Import occasional skills into Stash's managed store, or configure an + existing read-only library. 3. Invoke Stash explicitly. 4. Stash resolves an exact name or runs local lexical search. 5. It reads the selected `SKILL.md` and only the required resources. @@ -94,6 +96,38 @@ defaults: Set `STASH_CONFIG` to the configuration file. You can also use `--root ` for a one-off call. +### Managed inactive skills + +No catalog configuration is required for the managed store. Import accepts a +local skill directory and leaves the source untouched: + +```bash +stash install D:/downloads/rare-skill +stash archive old-skill --host codex +stash status rare-skill +stash activate rare-skill --host codex +stash deactivate rare-skill --host codex +``` + +`install`, `import`, and `add` are aliases. `archive` is the destructive form: +it verifies and stores an explicitly selected standalone skill before removing +that source directory from host discovery. It never manages a plugin-contained +skill. `activate` records a `deployed` copy; it does not claim that a host-level +enable/disable override is enabled. + +The CLI imports local directories only. When a user explicitly asks the Stash +skill to import a repository skill, the agent may stage the pinned revision +outside host discovery, inspect it, and pass that local directory to `install`. +Install may read a selected skill inside a configured catalog but never mutates +that source. When a hash-matching source or Stash-owned deployment also appears +in an indexed catalog, search folds it into the managed canonical result as a +related copy. A drifted or unrelated copy remains separate and visible. + +The first lifecycle release is intentionally local-only: no remote Git source, +symlink deployment, overwrite, plugin mutation, vendor setting mutation, or +workspace lifecycle target. Antigravity CLI lifecycle is rejected because its +documented standalone skill layouts are flat Markdown rather than directories. + ## Vendor support | Vendor | Explicit use | Automatic selection | @@ -108,12 +142,13 @@ Antigravity adapters are generated, but should be tested against the target ## Boundaries -- Source libraries remain read-only. +- External source libraries remain read-only. Writes are restricted to the + Stash-managed root and explicit standalone lifecycle targets. - Search uses no network, embedding model, vector database, or second LLM router. - Reading a skill does not execute its scripts. -- Stash is not an installer, updater, marketplace, permission system, sandbox, - or security scanner. +- Stash is not a marketplace, remote updater, permission system, sandbox, or + security scanner. Plugin lifecycle remains owned by each host. ## Documentation diff --git a/SECURITY.md b/SECURITY.md index c6da5ba..5188bbf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,24 @@ ## Scope -Stash discovers and reads local Agent Skills. A skill can contain untrusted instructions, scripts, URLs, and assets. Treat adding a catalog like adding source code. +Stash discovers, reads, and explicitly stores local Agent Skills. A skill can contain untrusted instructions, scripts, URLs, and assets. Treat adding a catalog or importing a managed skill like adding source code. ## Guarantees -- Stash never edits configured catalog files. +- Stash never edits external configured catalog files. +- Managed imports reject symlinks, junctions, special files, non-portable path + names, case-insensitive collisions, oversized trees, and overwrites. +- Lifecycle copies are staged and tree-hash verified before atomic rename. +- Destructive operations apply only to explicitly selected standalone skills + or recorded deployments. Untracked and drifted deployments are preserved. +- Archive recovery is journaled. A rollback never overwrites an occupied source + path, and a committed tombstone is deleted only after its tree hash matches. +- Deactivation requires matching Stash ownership, skill/deployment identity, + target, and tree hash. +- Catalog overlap grants no write authority. Hash-matching related copies are + folded only in the read projection; drifted or unrelated copies stay visible. +- Lifecycle lock ownership is atomically published. Proven-dead owners are + reclaimed under a separate guard; malformed or live ownership fails closed. - Indexing does not execute scripts. - Resource paths must stay inside the selected skill and catalog roots after `realpath`. - Absolute paths and `..` traversal are rejected. @@ -24,6 +37,9 @@ Stash discovers and reads local Agent Skills. A skill can contain untrusted inst - Stash does not sandbox scripts executed later by a host agent. - A well-formed skill can still contain malicious or misleading instructions. - Lexical relevance is not a security classifier. +- `deployed` means present in a host discovery root; it does not prove that a + host enable/disable override is enabled. +- Plugin lifecycle and vendor setting changes are outside Stash lifecycle. ## Catalog review diff --git a/adapters/antigravity/cli/plugin.json b/adapters/antigravity/cli/plugin.json index 60f8a41..54f89f5 100644 --- a/adapters/antigravity/cli/plugin.json +++ b/adapters/antigravity/cli/plugin.json @@ -1,5 +1,5 @@ { "$schema": "https://antigravity.google/schemas/v1/plugin.json", "name": "stash", - "description": "Search and load local Agent Skills from a separate SKILL.md library on demand." + "description": "Search read-only Agent Skills catalogs and manage an explicit local inactive store." } diff --git a/adapters/antigravity/cli/scripts/stash.mjs b/adapters/antigravity/cli/scripts/stash.mjs index 7375d61..c1d2319 100644 --- a/adapters/antigravity/cli/scripts/stash.mjs +++ b/adapters/antigravity/cli/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path5) { - const ctrl = callVisitor(key, node, visitor, path5); + function visit_(key, node, visitor, path7) { + const ctrl = callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visit_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visit_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path5); + const ci = visit_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = visit_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = visit_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path5); + const cv = visit_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path5) { - const ctrl = await callVisitor(key, node, visitor, path5); + async function visitAsync_(key, node, visitor, path7) { + const ctrl = await callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visitAsync_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visitAsync_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path5); + const ci = await visitAsync_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path5); + const cv = await visitAsync_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path5) { + function callVisitor(key, node, visitor, path7) { if (typeof visitor === "function") - return visitor(key, node, path5); + return visitor(key, node, path7); if (identity.isMap(node)) - return visitor.Map?.(key, node, path5); + return visitor.Map?.(key, node, path7); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path5); + return visitor.Seq?.(key, node, path7); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path5); + return visitor.Pair?.(key, node, path7); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path5); + return visitor.Scalar?.(key, node, path7); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path5); + return visitor.Alias?.(key, node, path7); return void 0; } - function replaceNode(key, path5, node) { - const parent = path5[path5.length - 1]; + function replaceNode(key, path7, node) { + const parent = path7[path7.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path5, value) { + function collectionFromPath(schema, path7, value) { let v = value; - for (let i = path5.length - 1; i >= 0; --i) { - const k = path5[i]; + for (let i = path7.length - 1; i >= 0; --i) { + const k = path7[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path5) => path5 == null || typeof path5 === "object" && !!path5[Symbol.iterator]().next().done; + var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path5, value) { - if (isEmptyPath(path5)) + addIn(path7, value) { + if (isEmptyPath(path7)) this.add(value); else { - const [key, ...rest] = path5; + const [key, ...rest] = path7; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - const [key, ...rest] = path5; + deleteIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - const [key, ...rest] = path5; + getIn(path7, keepScalar) { + const [key, ...rest] = path7; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path5) { - const [key, ...rest] = path5; + hasIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - const [key, ...rest] = path5; + setIn(path7, value) { + const [key, ...rest] = path7; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path5, value) { + addIn(path7, value) { if (assertCollection(this.contents)) - this.contents.addIn(path5, value); + this.contents.addIn(path7, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - if (Collection.isEmptyPath(path5)) { + deleteIn(path7) { + if (Collection.isEmptyPath(path7)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path5) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - if (Collection.isEmptyPath(path5)) + getIn(path7, keepScalar) { + if (Collection.isEmptyPath(path7)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path5, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path5) { - if (Collection.isEmptyPath(path5)) + hasIn(path7) { + if (Collection.isEmptyPath(path7)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path5) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - if (Collection.isEmptyPath(path5)) { + setIn(path7, value) { + if (Collection.isEmptyPath(path7)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path5), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path5, value); + this.contents.setIn(path7, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path5) => { + visit.itemAtPath = (cst, path7) => { let item = cst; - for (const [field, index] of path5) { + for (const [field, index] of path7) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path5) => { - const parent = visit.itemAtPath(cst, path5.slice(0, -1)); - const field = path5[path5.length - 1][0]; + visit.parentCollection = (cst, path7) => { + const parent = visit.itemAtPath(cst, path7.slice(0, -1)); + const field = path7[path7.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path5, item, visitor) { - let ctrl = visitor(item, path5); + function _visit(path7, item, visitor) { + let ctrl = visitor(item, path7); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path5.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path5); + ctrl = ctrl(item, path7); } } - return typeof ctrl === "function" ? ctrl(item, path5) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; } exports.visit = visit; } @@ -7263,7 +7263,7 @@ var require_public_api = __commonJS({ } return doc; } - function parse3(src, reviver, options) { + function parse4(src, reviver, options) { let _reviver = void 0; if (typeof reviver === "function") { _reviver = reviver; @@ -7304,7 +7304,7 @@ var require_public_api = __commonJS({ return value.toString(options); return new Document.Document(value, _replacer, options).toString(options); } - exports.parse = parse3; + exports.parse = parse4; exports.parseAllDocuments = parseAllDocuments; exports.parseDocument = parseDocument; exports.stringify = stringify; @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile3, realpath as realpath2, stat as stat2 } from "node:fs/promises"; -import path4 from "node:path"; +import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; +import path5 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7538,6 +7538,33 @@ function platformCachePath() { "stash" ); } +function platformManagedPath() { + if (process.env.STASH_MANAGED_HOME) { + return path.resolve(process.env.STASH_MANAGED_HOME); + } + const currentPlatform = platform(); + if (currentPlatform === "win32") { + return path.join( + process.env.LOCALAPPDATA ?? path.join(homedir(), "AppData", "Local"), + "stash", + "managed" + ); + } + if (currentPlatform === "darwin") { + return path.join( + homedir(), + "Library", + "Application Support", + "stash", + "managed" + ); + } + return path.join( + process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share"), + "stash", + "managed" + ); +} function safeCatalogSegment(value) { return value.replace(/[^a-zA-Z0-9._-]/gu, "_"); } @@ -7668,16 +7695,21 @@ function parseDefaults(value, overrides) { async function loadConfiguration(options) { const cacheDir = path2.resolve(options.cacheDir ?? platformCachePath()); if (options.catalogs) { - const catalogs = options.catalogs.map((catalog) => ({ + let catalogs2 = options.catalogs.map((catalog) => ({ ...catalog, root: path2.resolve(catalog.root) })); - validateUniqueCatalogIds(catalogs); + const managedRoot2 = options.managedRoot ? path2.resolve(options.managedRoot) : void 0; + if (managedRoot2) { + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + } + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + ...managedRoot2 ? { managedRoot: managedRoot2 } : {} }, cacheDir }; @@ -7694,23 +7726,29 @@ async function loadConfiguration(options) { 5 ); } - if (!Array.isArray(parsed.catalogs)) { + if (parsed.catalogs !== void 0 && !Array.isArray(parsed.catalogs)) { throw new StashError( "invalid-config", - `Config "${configPath}" must define a catalogs array.`, + `Config "${configPath}" catalogs must be an array.`, 2 ); } const baseDir = path2.dirname(configPath); - const catalogs = parsed.catalogs.map( + let catalogs2 = (Array.isArray(parsed.catalogs) ? parsed.catalogs : []).map( (catalog, index) => parseCatalog(catalog, baseDir, index) ); - validateUniqueCatalogIds(catalogs); + const configuredManagedRoot = typeof parsed.managedRoot === "string" && parsed.managedRoot.trim() ? path2.resolve(baseDir, parsed.managedRoot) : void 0; + const managedRoot2 = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? configuredManagedRoot ?? platformManagedPath() + ); + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(parsed.defaults, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(parsed.defaults, options.defaults), + managedRoot: managedRoot2 }, cacheDir, configPath @@ -7728,29 +7766,92 @@ async function loadConfiguration(options) { ); } } + const managedRoot = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ); if (process.env.STASH_HOME) { + const catalogs2 = await includeManagedCatalog( + [ + { + id: "default", + root: path2.resolve(process.env.STASH_HOME), + enabled: true, + trust: "unreviewed", + followSymlinks: false + } + ], + managedRoot + ); return { configuration: { version: 1, - catalogs: [ - { - id: "default", - root: path2.resolve(process.env.STASH_HOME), - enabled: true, - trust: "unreviewed", - followSymlinks: false - } - ], - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + managedRoot }, cacheDir }; } - throw new StashError( - "config-not-found", - `Stash config was not found at "${configPath}". Set STASH_CONFIG, STASH_HOME, or pass --config.`, - 2 - ); + if (explicitConfig) { + throw new StashError( + "config-not-found", + `Stash config was not found at "${configPath}".`, + 2 + ); + } + const catalogs = await includeManagedCatalog([], managedRoot); + return { + configuration: { + version: 1, + catalogs, + defaults: parseDefaults(void 0, options.defaults), + managedRoot + }, + cacheDir + }; +} +async function includeManagedCatalog(catalogs, managedRoot) { + if (catalogs.some((catalog) => catalog.id === "managed")) { + throw new StashError( + "invalid-config", + 'Catalog id "managed" is reserved for the Stash-managed store.', + 2 + ); + } + const normalizedManagedRoot = path2.resolve(managedRoot); + if (catalogs.some((catalog) => { + const normalizedCatalogRoot = path2.resolve(catalog.root); + return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; + })) { + return catalogs; + } + try { + const info = await stat(managedRoot); + if (!info.isDirectory()) { + throw new StashError( + "invalid-config", + `Managed root is not a directory: "${managedRoot}".`, + 2 + ); + } + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return catalogs; + } + throw error; + } + return [ + ...catalogs, + { + id: "managed", + root: managedRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ]; } function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); @@ -7775,7 +7876,7 @@ import { readdir, realpath, rename, - stat, + stat as stat2, unlink, writeFile } from "node:fs/promises"; @@ -7878,7 +7979,7 @@ function parseSidecar(source, catalog) { }; } async function readBounded(filePath, maxBytes) { - const info = await stat(filePath); + const info = await stat2(filePath); if (info.size > maxBytes) { throw new Error(`File exceeds ${maxBytes} bytes.`); } @@ -7889,7 +7990,7 @@ async function loadManifest(root, catalog, warnings) { let input = {}; let fingerprintPart = ""; try { - const manifestInfo = await stat(manifestPath); + const manifestInfo = await stat2(manifestPath); input = asObject2((0, import_yaml2.parse)(await readBounded(manifestPath, MAX_SIDECAR_BYTES))); fingerprintPart = [ "stash.catalog.yaml", @@ -7961,7 +8062,7 @@ async function discoverCandidates(catalog) { 4 ); } - const rootInfo = await stat(root); + const rootInfo = await stat2(root); if (!rootInfo.isDirectory()) { throw new StashError( "catalog-unavailable", @@ -7987,13 +8088,13 @@ async function discoverCandidates(catalog) { ); if (skillEntry) { const skillFile = path3.join(directory, skillEntry.name); - const info = await stat(skillFile); + const info = await stat2(skillFile); const sidecarPath = path3.join(directory, "stash.meta.yaml"); let sidecarFile; let sidecarModifiedMs; let sidecarSize; try { - const sidecarInfo = await stat(sidecarPath); + const sidecarInfo = await stat2(sidecarPath); if (sidecarInfo.isFile()) { sidecarFile = sidecarPath; sidecarModifiedMs = sidecarInfo.mtimeMs; @@ -8038,7 +8139,7 @@ async function discoverCandidates(catalog) { }); continue; } - const targetInfo = await stat(target); + const targetInfo = await stat2(target); if (targetInfo.isDirectory()) { await walk(target, depth + 1); } @@ -8234,7 +8335,7 @@ async function acquireLock(lockPath) { throw error; } try { - const lockInfo = await stat(lockPath); + const lockInfo = await stat2(lockPath); if (Date.now() - lockInfo.mtimeMs > 3e4) { await unlink(lockPath); continue; @@ -8254,6 +8355,225 @@ async function acquireLock(lockPath) { } } +// src/internal/managed-projection.ts +import { + lstat, + readFile as readFile3, + readdir as readdir2, + realpath as realpath2, + stat as stat3 +} from "node:fs/promises"; +import { platform as platform2 } from "node:os"; +import path4 from "node:path"; +var MAX_FILES = 1e4; +var MAX_TOTAL_BYTES = 256 * 1024 * 1024; +function pathIdentity(value) { + const normalized = path4.resolve(value).normalize("NFKC"); + return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function validRecord(value) { + if (!value || typeof value !== "object") { + return false; + } + const record = value; + return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); +} +async function treeHash(rootInput) { + try { + const rootInfo = await lstat(rootInput); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + return void 0; + } + const root = await realpath2(rootInput); + const entries = []; + let files = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new Error("linked tree"); + } + if (before.isDirectory()) { + const canonical = await realpath2(childPath); + if (!isPathInside(root, canonical)) { + throw new Error("tree escape"); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new Error("special file"); + } + files += 1; + totalBytes += before.size; + if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new Error("tree too large"); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new Error("tree changed"); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return sha256(fingerprint); + } catch { + return void 0; + } +} +function relatedCopy(target, record) { + if (target.kind === "deployment" && target.deployment) { + return { + kind: "deployment", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId, + targetId: target.deployment.targetId, + host: target.deployment.host, + scope: target.deployment.scope + }; + } + return { + kind: "source", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId + }; +} +async function projectManagedCopies(sourceIndexes, managedRoot) { + if (!managedRoot || !sourceIndexes.some((index) => index.catalogId === "managed")) { + return { indexes: sourceIndexes, fingerprintPart: "" }; + } + const indexes = sourceIndexes.map((index) => ({ + ...index, + records: index.records.map((record) => ({ ...record })), + warnings: [...index.warnings] + })); + const managedIndex = indexes.find((index) => index.catalogId === "managed"); + if (!managedIndex) { + return { indexes, fingerprintPart: "" }; + } + let recordFiles; + const recordsRoot = path4.join(managedRoot, ".stash", "records"); + try { + recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + } catch { + return { indexes, fingerprintPart: "" }; + } + const targets = /* @__PURE__ */ new Map(); + const managedRecords = /* @__PURE__ */ new Map(); + for (const file of recordFiles) { + try { + const parsed = JSON.parse( + await readFile3(path4.join(recordsRoot, file), "utf8") + ); + if (!validRecord(parsed)) { + throw new Error("invalid record"); + } + managedRecords.set(parsed.name, parsed); + targets.set(pathIdentity(parsed.source.location), { + kind: "source", + record: parsed, + expectedTreeHash: parsed.treeHash + }); + for (const deployment of parsed.deployments) { + if (deployment.ownership === "stash" && deployment.skillId === parsed.skillId) { + targets.set(pathIdentity(deployment.path), { + kind: "deployment", + record: parsed, + expectedTreeHash: deployment.treeHash, + deployment + }); + } + } + } catch { + managedIndex.warnings.push({ + code: "invalid-lifecycle-record", + message: `Ignored invalid lifecycle projection record "${file}".`, + path: `.stash/records/${file}` + }); + } + } + const canonicalBySkillId = /* @__PURE__ */ new Map(); + for (const record of managedIndex.records) { + const managedRecord = managedRecords.get(record.name); + if (managedRecord) { + record.managedSkillId = managedRecord.skillId; + record.relatedCopies = []; + canonicalBySkillId.set(managedRecord.skillId, record); + } + } + const events = []; + for (const index of indexes) { + if (index.catalogId === "managed") { + continue; + } + const visible = []; + for (const record of index.records) { + const skillRoot = path4.dirname( + path4.resolve(index.root, record.relativeSkillFile) + ); + const target = targets.get(pathIdentity(skillRoot)); + const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; + if (!target || !canonical) { + visible.push(record); + continue; + } + const actualTreeHash = await treeHash(skillRoot); + if (actualTreeHash !== target.expectedTreeHash) { + visible.push(record); + index.warnings.push({ + code: "managed-copy-drift", + message: `Managed-related ${target.kind} "${record.ref}" drifted and remains a separate result.`, + ref: record.ref, + path: record.relativeSkillFile + }); + events.push(`drift:${record.ref}:${actualTreeHash ?? "unavailable"}`); + continue; + } + const related = relatedCopy(target, record); + canonical.relatedCopies = [...canonical.relatedCopies ?? [], related]; + events.push( + `fold:${record.ref}:${target.record.skillId}:${target.kind}:${actualTreeHash}` + ); + } + index.records = visible; + } + for (const record of managedIndex.records) { + if (record.relatedCopies) { + record.relatedCopies.sort( + (left, right) => `${left.kind}:${left.catalogId}:${left.ref}`.localeCompare( + `${right.kind}:${right.catalogId}:${right.ref}`, + "en" + ) + ); + } + } + return { + indexes, + fingerprintPart: sha256(events.sort().join("\n")) + }; +} + // src/internal/search.ts var ROUTING_PROFILE_VERSION = 4; var FIELD_WEIGHTS = { @@ -8583,7 +8903,9 @@ function toListedSkill(record) { compatibility: record.compatibility, trust: record.trust, ...Object.keys(record.source).length > 0 ? { source: record.source } : {}, - contentHash: record.contentHash + contentHash: record.contentHash, + ...record.managedSkillId ? { managedSkillId: record.managedSkillId } : {}, + ...record.relatedCopies && record.relatedCopies.length > 0 ? { relatedCopies: record.relatedCopies } : {} }; } @@ -8623,7 +8945,7 @@ var StashCatalogImplementation = class { this.#cacheDir = cacheDir; this.#now = now; } - async #loadIndexes(catalogIds) { + async #loadIndexes(catalogIds, applyManagedProjection = true) { const selected = this.#selectRegistrations(catalogIds); const indexes = []; for (const catalog of selected) { @@ -8635,12 +8957,18 @@ var StashCatalogImplementation = class { ); indexes.push(loaded.index); } + const projected = applyManagedProjection ? await projectManagedCopies(indexes, this.#configuration.managedRoot) : { indexes, fingerprintPart: "" }; return { - indexes, + indexes: projected.indexes, registrations: selected, - warnings: indexes.flatMap((index) => index.warnings), + warnings: projected.indexes.flatMap((index) => index.warnings), fingerprint: sha256( - indexes.map((index) => `${index.catalogId}:${index.fingerprint}`).sort().join("\n") + [ + ...projected.indexes.map( + (index) => `${index.catalogId}:${index.fingerprint}` + ), + projected.fingerprintPart + ].sort().join("\n") ) }; } @@ -8900,7 +9228,7 @@ var StashCatalogImplementation = class { resource }; } - const loaded = await this.#loadIndexes(); + const loaded = await this.#loadIndexes(void 0, false); let record; let index; for (const candidateIndex of loaded.indexes) { @@ -8929,10 +9257,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path4.resolve(skillRoot, normalizedResource); + const candidatePath = path5.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8944,7 +9272,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath2(candidatePath); + resolvedPath = await realpath3(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8953,7 +9281,7 @@ var StashCatalogImplementation = class { resource }; } - const info = await stat2(resolvedPath); + const info = await stat4(resolvedPath); if (!info.isFile()) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9005,7 +9333,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile3(resolvedPath); + const buffer = await readFile4(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9120,140 +9448,1532 @@ async function createStashCatalog(options = {}) { ); } -// src/cli.ts -function parseArguments(argv) { - const [command, ...rest] = argv; - const positionals = []; - const flags2 = /* @__PURE__ */ new Map(); - for (let index = 0; index < rest.length; index += 1) { - const current = rest[index]; - if (!current) { - continue; - } - if (!current.startsWith("--")) { - positionals.push(current); - continue; - } - const [rawName, inlineValue] = current.slice(2).split("=", 2); - const name = rawName ?? ""; - let value = inlineValue; - if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { - value = rest[index + 1]; - index += 1; - } - const values = flags2.get(name) ?? []; - values.push(value ?? "true"); - flags2.set(name, values); - } - return { ...command ? { command } : {}, positionals, flags: flags2 }; -} -function flag(args, name) { - return args.flags.get(name)?.at(-1); -} -function flags(args, name) { - const values = args.flags.get(name); - return values && values.length > 0 ? values : void 0; +// src/stash-lifecycle.ts +var import_yaml3 = __toESM(require_dist(), 1); +import { randomUUID } from "node:crypto"; +import { + lstat as lstat2, + mkdir as mkdir2, + readFile as readFile5, + readdir as readdir3, + realpath as realpath4, + rename as rename2, + rm, + stat as stat5, + unlink as unlink2, + writeFile as writeFile2 +} from "node:fs/promises"; +import { homedir as homedir2, platform as platform3 } from "node:os"; +import path6 from "node:path"; +var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +var MAX_FILES2 = 1e4; +var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; +var STORE_SCHEMA_VERSION = 1; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +function asObject3(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } -function booleanFlag(args, name) { - return args.flags.has(name) && flag(args, name) !== "false"; +function compatibilityState2(value) { + return value === "supported" || value === "partial" || value === "unsupported" || value === "unknown" ? value : "unknown"; } -function numberFlag(args, name) { - const value = flag(args, name); - if (value === void 0) { - return void 0; +function parseSkillMetadata(snapshot) { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec( + snapshot.skillSource.replace(/^\uFEFF/u, "") + ); + if (!match?.[1]) { + throw new StashError( + "invalid-skill", + "SKILL.md must contain closed YAML frontmatter.", + 3 + ); } - const parsed = Number(value); - if (!Number.isFinite(parsed)) { + const metadata = asObject3((0, import_yaml3.parse)(match[1])); + if (typeof metadata.name !== "string" || metadata.name.length > 64 || !NAME_PATTERN2.test(metadata.name)) { throw new StashError( - "invalid-argument", - `--${name} must be a number.`, - 2 + "invalid-skill", + "SKILL.md name must use 1-64 lowercase letters, digits, or hyphens.", + 3 ); } - return parsed; -} -function createOptions(args) { - const root = flag(args, "root"); - const catalogId = flag(args, "root-id") ?? "default"; - const configPath = flag(args, "config"); - const cacheDir = flag(args, "cache-dir"); - const catalogs = root ? [ - { - id: catalogId, - root, - enabled: true, - trust: "unreviewed", - followSymlinks: false, - compatibility: ["codex", "claude-code", "antigravity"] + if (typeof metadata.description !== "string" || metadata.description.trim() === "" || metadata.description.length > 1024) { + throw new StashError( + "invalid-skill", + "SKILL.md description must be a non-empty string up to 1024 characters.", + 3 + ); + } + let compatibility = {}; + if (snapshot.sidecarSource) { + const sidecar = asObject3((0, import_yaml3.parse)(snapshot.sidecarSource)); + if (sidecar.schemaVersion !== void 0 && sidecar.schemaVersion !== 1) { + throw new StashError( + "invalid-skill", + "stash.meta.yaml must use schemaVersion: 1.", + 3 + ); } - ] : void 0; + compatibility = asObject3(sidecar.compatibility); + } return { - ...configPath ? { configPath } : {}, - ...cacheDir ? { cacheDir } : {}, - ...catalogs ? { catalogs } : {} + name: metadata.name, + compatibility: { + codex: compatibilityState2(compatibility.codex), + "claude-code": compatibilityState2(compatibility["claude-code"]), + antigravity: compatibilityState2(compatibility.antigravity) + } }; } -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)} -`); +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); } -function printResolve(result) { - if (result.status === "no-match") { - process.stdout.write( - result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. -` : "No matching skills.\n" +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot end in a dot or space: "${segment}".`, + 3 ); - return; - } - if (result.status !== "ok" && result.status !== "ambiguous-exact") { - process.stdout.write(`Stash: ${result.status} -`); - return; - } - process.stdout.write( - `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" - ); - let currentScope = ""; - for (const match of result.matches) { - const group = match.group ?? "(ungrouped)"; - const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; - const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; - if (scope !== currentScope) { - currentScope = scope; - process.stdout.write(` -${scope} -`); - } - const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; - process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} -`); - process.stdout.write(` ref: ${match.ref} -`); } - if (result.page.nextCursor) { - process.stdout.write(` -next_cursor: ${result.page.nextCursor} -`); + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot use the reserved name "${segment}".`, + 3 + ); } } -function usage() { - return `Stash \u2014 on-demand search for local Agent Skills - -Usage: - stash exact [--source ] [--group ] [--catalog ] [--json] - stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] - stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] +async function snapshotTree(sourceRoot) { + const rootInput = path6.resolve(sourceRoot); + const rootInfo = await lstat2(rootInput).catch((error) => { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${rootInput}": ${String(error)}`, + 4 + ); + }); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new StashError( + "unsafe-skill-tree", + `Skill root must be a real directory, not a link: "${rootInput}".`, + 3 + ); + } + const root = await realpath4(rootInput); + const entries = []; + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + let skillSource; + let sidecarSource; + async function walk(directory, relativeDirectory) { + const children = await readdir3(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new StashError( + "unsafe-skill-tree", + `Case-insensitive path collision at "${relativePath}".`, + 3 + ); + } + pathKeys.add(key); + const childPath = path6.join(directory, child.name); + const before = await lstat2(childPath); + if (before.isSymbolicLink()) { + throw new StashError( + "unsafe-skill-tree", + `Symlinks and junctions are not allowed: "${relativePath}".`, + 3 + ); + } + if (before.isDirectory()) { + const canonical = await realpath4(childPath); + if (!isPathInside(root, canonical)) { + throw new StashError( + "unsafe-skill-tree", + `Directory escapes the skill root: "${relativePath}".`, + 3 + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new StashError( + "unsafe-skill-tree", + `Only regular files and directories are allowed: "${relativePath}".`, + 3 + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { + throw new StashError( + "skill-too-large", + `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, + 3 + ); + } + const content = await readFile5(childPath); + const after = await stat5(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new StashError( + "source-changed", + `Skill changed while it was being read: "${relativePath}".`, + 4 + ); + } + const contentHash = sha256(content); + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash + }); + if (relativePath === "SKILL.md") { + skillSource = content.toString("utf8"); + } else if (relativePath === "stash.meta.yaml") { + sidecarSource = content.toString("utf8"); + } + } + } + await walk(root, ""); + if (skillSource === void 0) { + throw new StashError( + "invalid-skill", + `Skill root must contain SKILL.md: "${root}".`, + 3 + ); + } + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + skillSource, + ...sidecarSource !== void 0 ? { sidecarSource } : {} + }; +} +async function copySnapshot(snapshot, destination) { + await mkdir2(destination, { recursive: false }); + for (const entry of snapshot.entries) { + const relative = entry.relativePath.split("/").join(path6.sep); + const target = path6.join(destination, relative); + if (entry.kind === "directory") { + await mkdir2(target, { recursive: false }); + continue; + } + const source = path6.join(snapshot.root, relative); + const content = await readFile5(source); + if (sha256(content) !== entry.contentHash) { + throw new StashError( + "source-changed", + `Skill changed while it was being copied: "${entry.relativePath}".`, + 4 + ); + } + await writeFile2(target, content, { flag: "wx" }); + } +} +async function pathType(target) { + try { + const info = await lstat2(target); + if (info.isSymbolicLink()) { + return "link"; + } + if (info.isDirectory()) { + return "directory"; + } + return "other"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return "missing"; + } + throw error; + } +} +function samePath(left, right) { + const normalizedLeft = path6.resolve(left).normalize("NFKC"); + const normalizedRight = path6.resolve(right).normalize("NFKC"); + return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; +} +function pathIdentity2(value) { + const normalized = path6.resolve(value).normalize("NFKC"); + return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function targetIdentity(target) { + return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; +} +function resolveHostRoot(target) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace") { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + if (target.root) { + return { + host: target.host, + scope: target.scope ?? "custom", + root: path6.resolve(target.root) + }; + } + const scope = target.scope ?? "user"; + if (scope === "custom") { + throw new StashError( + "invalid-argument", + "A custom lifecycle target requires root.", + 2 + ); + } + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".gemini", "config", "skills") + }; + } +} +async function isPluginContained(source) { + let current = path6.dirname(source); + for (let depth = 0; depth < 12; depth += 1) { + const markers = [ + path6.join(current, ".claude-plugin", "plugin.json"), + path6.join(current, ".codex-plugin", "plugin.json"), + path6.join(current, "plugin.json") + ]; + for (const marker of markers) { + if (await pathType(marker) !== "missing") { + return true; + } + } + const parent = path6.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return false; +} +var StashLifecycleImplementation = class { + #managedRoot; + #now; + constructor(managedRoot, now) { + this.#managedRoot = path6.resolve(managedRoot); + this.#now = now; + } + #metadataRoot() { + return path6.join(this.#managedRoot, ".stash"); + } + #recordPath(name) { + return path6.join(this.#metadataRoot(), "records", `${name}.json`); + } + async #ensureLayout() { + await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + try { + await writeFile2( + manifestPath, + "schemaVersion: 1\nid: managed\ndefaults:\n trust: unreviewed\n compatibility:\n - codex\n - claude-code\n - antigravity\nskillDiscovery:\n ignore:\n - .stash\n", + { encoding: "utf8", flag: "wx" } + ); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "EEXIST") { + throw error; + } + } + } + #journalPath(operationId) { + return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + } + async #writeJournal(journal) { + const finalPath = this.#journalPath(journal.operationId); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(journal, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #advanceArchiveJournal(journal, stage) { + const next = { ...journal, stage }; + await this.#writeJournal(next); + journal.stage = stage; + } + async #removeIncompleteManaged(journal) { + if (journal.managedExistedBefore) { + return; + } + const record = await this.#readRecord(journal.name); + if (record && record.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed record changed during archive recovery: "${journal.name}".`, + 4 + ); + } + if (await pathType(journal.managedPath) === "directory") { + const snapshot = await snapshotTree(journal.managedPath); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed tree changed during archive recovery: "${journal.managedPath}".`, + 4 + ); + } + await rm(journal.managedPath, { recursive: true, force: false }); + } + await unlink2(this.#recordPath(journal.name)).catch((error) => { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + }); + } + #validateArchiveJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "managed-committed", + "source-tombstoned", + "archive-committed" + ]); + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + const expectedManagedPath = path6.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + `.stash-archive-${journal.name}-` + ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + } + async #recoverArchiveJournal(journal, journalPath) { + const sourceType = await pathType(journal.source); + const tombstoneType = await pathType(journal.tombstone); + if (journal.stage === "archive-committed") { + if (tombstoneType !== "missing") { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone is not a directory: "${journal.tombstone}".`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rm(journal.tombstone, { recursive: true, force: false }); + } + await unlink2(journalPath); + return; + } + const sourceWasMoved = journal.stage === "source-tombstoned" || sourceType === "missing" && tombstoneType !== "missing"; + if (sourceWasMoved) { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive source and tombstone cannot be reconciled for "${journal.name}".`, + 4 + ); + } + if (sourceType !== "missing") { + throw new StashError( + "archive-restore-conflict", + `Archive recovery preserved "${journal.tombstone}" because "${journal.source}" is occupied.`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rename2(journal.tombstone, journal.source); + } + await this.#removeIncompleteManaged(journal); + await unlink2(journalPath); + } + async #recoverJournals() { + const journalRoot = path6.join(this.#metadataRoot(), "journal"); + const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + for (const file of files) { + const journalPath = path6.join(journalRoot, file); + let journal; + try { + journal = JSON.parse(await readFile5(journalPath, "utf8")); + } catch (error) { + throw new StashError( + "invalid-lifecycle-journal", + `Unable to read lifecycle journal "${journalPath}": ${String(error)}`, + 5 + ); + } + this.#validateArchiveJournal(journal, journalPath); + await this.#recoverArchiveJournal(journal, journalPath); + } + } + async #readLockOwner(lockPath) { + let owner; + try { + owner = JSON.parse( + await readFile5(path6.join(lockPath, "owner.json"), "utf8") + ); + } catch (error) { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is missing or malformed at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + if (owner.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(owner.ownerToken) || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.createdAt !== "string") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is invalid at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + return owner; + } + #ownerIsAlive(owner) { + try { + process.kill(owner.pid, 0); + return true; + } catch { + return false; + } + } + async #publishLock(lockPath, owner) { + const temporaryPath = path6.join( + this.#metadataRoot(), + `.lifecycle-lock-${owner.ownerToken}.tmp` + ); + await mkdir2(temporaryPath, { recursive: false }); + try { + await writeFile2( + path6.join(temporaryPath, "owner.json"), + `${JSON.stringify(owner)} +`, + { encoding: "utf8", flag: "wx" } + ); + try { + await rename2(temporaryPath, lockPath); + return true; + } catch (error) { + if (await pathType(lockPath) === "directory") { + return false; + } + throw error; + } + } finally { + await rm(temporaryPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #reclaimDeadLock(lockPath) { + const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + try { + await mkdir2(guardPath, { recursive: false }); + } catch (error) { + if (await pathType(guardPath) === "directory") { + throw new StashError( + "lifecycle-busy", + `Another process is checking stale lifecycle ownership at "${lockPath}". If no Stash process is running, follow the documented reclaim-guard repair procedure.`, + 4 + ); + } + throw error; + } + try { + if (await pathType(lockPath) === "missing") { + return; + } + if (await pathType(lockPath) !== "directory") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock is not a directory at "${lockPath}".`, + 4 + ); + } + const owner = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(owner)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + const reclaimedPath = path6.join( + this.#metadataRoot(), + `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` + ); + await rename2(lockPath, reclaimedPath); + const reclaimedOwner = await this.#readLockOwner(reclaimedPath); + if (reclaimedOwner.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-corrupt", + "Lifecycle lock ownership changed during stale recovery.", + 4 + ); + } + await rm(reclaimedPath, { recursive: true, force: false }); + } finally { + await rm(guardPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #acquireLock(lockPath) { + const owner = { + schemaVersion: 1, + ownerToken: randomUUID(), + pid: process.pid, + createdAt: new Date(this.#now()).toISOString() + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await this.#publishLock(lockPath, owner)) { + return owner; + } + const existing = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(existing)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + await this.#reclaimDeadLock(lockPath); + } + throw new StashError( + "lifecycle-busy", + `Lifecycle ownership changed while acquiring "${lockPath}".`, + 4 + ); + } + async #releaseLock(lockPath, owner) { + const current = await this.#readLockOwner(lockPath); + if (current.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-lost", + `Lifecycle lock ownership changed at "${lockPath}".`, + 4 + ); + } + await rm(lockPath, { recursive: true, force: false }); + } + async #withLock(operation) { + await this.#ensureLayout(); + const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const owner = await this.#acquireLock(lockPath); + try { + await this.#recoverJournals(); + return await operation(); + } finally { + await this.#releaseLock(lockPath, owner); + } + } + async #readRecord(name) { + const recordPath = this.#recordPath(name); + try { + const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + )) { + throw new Error("invalid lifecycle record shape"); + } + return parsed; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return void 0; + } + throw new StashError( + "invalid-lifecycle-record", + `Unable to read lifecycle record "${recordPath}": ${String(error)}`, + 5 + ); + } + } + async #writeRecord(record) { + const finalPath = this.#recordPath(record.name); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(record, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #assertSourceBoundary(source) { + const canonicalSource = await realpath4(source); + const canonicalManaged = await realpath4(this.#managedRoot); + if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { + throw new StashError( + "unsafe-source", + "A lifecycle source cannot be inside, contain, or equal the managed root.", + 3 + ); + } + } + async #canonicalHostRoot(root, create, allowMissing = false) { + let type = await pathType(root); + if (type === "missing" && create) { + await mkdir2(root, { recursive: true }); + type = await pathType(root); + } + if (type === "missing" && allowMissing) { + return path6.resolve(root); + } + if (type !== "directory") { + throw new StashError( + "unsafe-host-root", + `Host root must be a real directory, not a link or special path: "${root}".`, + 3 + ); + } + return realpath4(root); + } + async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { + await this.#assertSourceBoundary(source); + const snapshot = await snapshotTree(source); + if (expectedTreeHash && snapshot.treeHash !== expectedTreeHash) { + throw new StashError( + "source-changed", + `Skill changed before its managed copy could be committed: "${source}".`, + 4 + ); + } + const metadata = parseSkillMetadata(snapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const existingType = await pathType(managedPath); + if (existingType !== "missing") { + if (existingType !== "directory") { + throw new StashError( + "managed-conflict", + `Managed target is not a directory: "${managedPath}".`, + 3 + ); + } + const existingRecord = await this.#readRecord(metadata.name); + const existingSnapshot = await snapshotTree(managedPath); + if (existingRecord && existingRecord.treeHash === snapshot.treeHash && existingSnapshot.treeHash === snapshot.treeHash) { + return { + record: existingRecord, + managedPath, + created: false + }; + } + throw new StashError( + "managed-conflict", + `Managed skill "${metadata.name}" already exists with different content or metadata.`, + 3 + ); + } + if (await this.#readRecord(metadata.name)) { + throw new StashError( + "managed-conflict", + `Lifecycle metadata exists without its managed skill: "${metadata.name}".`, + 3 + ); + } + const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + let committed = false; + try { + await copySnapshot(snapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== snapshot.treeHash) { + throw new StashError( + "copy-verification-failed", + `Staged tree hash differs for "${metadata.name}".`, + 4 + ); + } + const timestamp = new Date(this.#now()).toISOString(); + const record = { + schemaVersion: STORE_SCHEMA_VERSION, + skillId: randomUUID(), + name: metadata.name, + treeHash: snapshot.treeHash, + source: { + kind, + location: snapshot.root, + importedAt: timestamp, + ...sourceUrl ? { url: sourceUrl } : {}, + ...revision ? { revision } : {} + }, + compatibility: metadata.compatibility, + deployments: [], + lastValidatedAt: timestamp + }; + await rename2(stage, managedPath); + committed = true; + try { + await this.#writeRecord(record); + } catch (error) { + await rm(managedPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { record, managedPath, created: true }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + } + async install(request) { + return this.#withLock(async () => { + const stored = await this.#storeSource( + path6.resolve(request.source), + "local-import", + request.sourceUrl, + request.revision + ); + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash + }; + }); + } + async archive(request) { + return this.#withLock(async () => { + const resolvedTarget = resolveHostRoot(request.target); + resolvedTarget.root = await this.#canonicalHostRoot( + resolvedTarget.root, + false + ); + const looksLikeName = NAME_PATTERN2.test(request.source); + const source = path6.resolve( + looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + ); + if (!samePath(path6.dirname(source), resolvedTarget.root)) { + throw new StashError( + "unsafe-archive-source", + `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, + 3 + ); + } + if (await isPluginContained(source)) { + throw new StashError( + "plugin-lifecycle-delegated", + "Plugin-contained skills must be managed by the host plugin manager.", + 3 + ); + } + await this.#assertSourceBoundary(source); + const sourceSnapshot = await snapshotTree(source); + const metadata = parseSkillMetadata(sourceSnapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; + const tombstoneParent = path6.dirname(resolvedTarget.root); + await mkdir2(tombstoneParent, { recursive: true }); + const tombstone = path6.join( + tombstoneParent, + `.stash-archive-${metadata.name}-${randomUUID()}` + ); + const journal = { + schemaVersion: 1, + operationId: randomUUID(), + stage: "started", + source, + tombstone, + name: metadata.name, + treeHash: sourceSnapshot.treeHash, + managedPath, + managedExistedBefore, + createdAt: new Date(this.#now()).toISOString() + }; + await this.#writeJournal(journal); + try { + const stored = await this.#storeSource( + source, + "standalone-archive", + request.sourceUrl, + request.revision, + journal.treeHash + ); + if (stored.record.name !== journal.name || stored.record.treeHash !== journal.treeHash || !samePath(stored.managedPath, journal.managedPath)) { + throw new StashError( + "source-changed", + `Archive source changed while the managed copy was being committed: "${source}".`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "managed-committed"); + await rename2(source, tombstone); + await this.#advanceArchiveJournal(journal, "source-tombstoned"); + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== stored.record.treeHash) { + throw new StashError( + "source-changed", + `Archived source changed before removal; recovery will preserve or restore it safely.`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "archive-committed"); + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `The skill is outside host discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + if (!warning) { + try { + await unlink2(this.#journalPath(journal.operationId)); + } catch (error) { + warning = `Archive committed, but its recovery journal remains for later cleanup: ${String(error)}`; + } + } + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash, + ...warning ? { warning } : {} + }; + } catch (error) { + await this.#recoverArchiveJournal( + journal, + this.#journalPath(journal.operationId) + ); + throw error; + } + }); + } + async activate(request) { + return this.#withLock(async () => { + if (!NAME_PATTERN2.test(request.name)) { + throw new StashError("invalid-argument", "Invalid skill name.", 2); + } + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${request.name}" no longer matches its recorded hash.`, + 3 + ); + } + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const existing = await pathType(deploymentPath); + const tracked = record.deployments.find( + (deployment) => samePath(deployment.path, deploymentPath) && deployment.targetId === targetId + ); + if (existing !== "missing") { + if (existing === "directory" && tracked) { + const existingSnapshot = await snapshotTree(deploymentPath); + if (existingSnapshot.treeHash === record.treeHash) { + return { + status: "already-deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment: tracked, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } + } + throw new StashError( + "deployment-conflict", + `Target already exists and will not be overwritten: "${deploymentPath}".`, + 3 + ); + } + const stage = path6.join( + path6.dirname(target.root), + `.stash-deploy-${record.name}-${randomUUID()}` + ); + let committed = false; + try { + await copySnapshot(managedSnapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "copy-verification-failed", + `Deployment verification failed for "${record.name}".`, + 4 + ); + } + await rename2(stage, deploymentPath); + committed = true; + const deployment = { + deploymentId: randomUUID(), + skillId: record.skillId, + targetId, + host: target.host, + scope: target.scope, + root: target.root, + path: deploymentPath, + method: "copy", + ownership: "stash", + treeHash: record.treeHash, + deployedAt: new Date(this.#now()).toISOString() + }; + record.deployments = [ + ...record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ), + deployment + ]; + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + await rm(deploymentPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { + status: "deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + }); + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, false, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 + ); + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path6.join( + path6.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); + throw new StashError( + "deployment-drift", + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, + 3 + ); + } + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); + throw error; + } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: target.host !== "claude-code", + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + }); + } + async status(request = {}) { + const recordsRoot = path6.join(this.#metadataRoot(), "records"); + let names; + if (request.name) { + names = [request.name]; + } else { + try { + names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + names = []; + } else { + throw error; + } + } + } + const skills = []; + for (const name of names) { + const record = await this.#readRecord(name); + if (!record) { + continue; + } + const managedPath = path6.join(this.#managedRoot, name); + const managedType = await pathType(managedPath); + let storeState = managedType === "missing" ? "missing" : "stored"; + let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; + let actualTreeHash; + if (managedType === "directory") { + try { + actualTreeHash = (await snapshotTree(managedPath)).treeHash; + storeIntegrity = actualTreeHash === record.treeHash ? "verified" : "drifted"; + } catch { + storeIntegrity = "drifted"; + } + } + const deployments = []; + for (const deployment of record.deployments) { + const type = await pathType(deployment.path); + if (type === "missing") { + deployments.push({ + ...deployment, + state: "missing", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "absent", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + if (type !== "directory") { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "drifted", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + try { + const deployedHash = (await snapshotTree(deployment.path)).treeHash; + deployments.push({ + ...deployment, + state: deployedHash === deployment.treeHash ? "deployed" : "drifted", + integrity: deployedHash === deployment.treeHash ? "verified" : "drifted", + actualTreeHash: deployedHash, + hostObservation: { + override: "unknown", + discovery: "present", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } catch { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } + } + skills.push({ + skillId: record.skillId, + name, + managedPath, + store: { + state: storeState, + integrity: storeIntegrity, + expectedTreeHash: record.treeHash, + ...actualTreeHash ? { actualTreeHash } : {} + }, + source: record.source, + deployments + }); + } + return { + status: skills.length > 0 ? "ok" : "not-found", + managedRoot: this.#managedRoot, + skills + }; + } +}; +async function createStashLifecycle(options = {}) { + const loaded = await loadConfiguration(options); + const managedRoot = path6.resolve( + loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() + ); + return new StashLifecycleImplementation( + managedRoot, + options.now ?? Date.now + ); +} + +// src/cli.ts +function parseArguments(argv) { + const [command, ...rest] = argv; + const positionals = []; + const flags2 = /* @__PURE__ */ new Map(); + for (let index = 0; index < rest.length; index += 1) { + const current = rest[index]; + if (!current) { + continue; + } + if (!current.startsWith("--")) { + positionals.push(current); + continue; + } + const [rawName, inlineValue] = current.slice(2).split("=", 2); + const name = rawName ?? ""; + let value = inlineValue; + if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { + value = rest[index + 1]; + index += 1; + } + const values = flags2.get(name) ?? []; + values.push(value ?? "true"); + flags2.set(name, values); + } + return { ...command ? { command } : {}, positionals, flags: flags2 }; +} +function flag(args, name) { + return args.flags.get(name)?.at(-1); +} +function flags(args, name) { + const values = args.flags.get(name); + return values && values.length > 0 ? values : void 0; +} +function booleanFlag(args, name) { + return args.flags.has(name) && flag(args, name) !== "false"; +} +function numberFlag(args, name) { + const value = flag(args, name); + if (value === void 0) { + return void 0; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new StashError( + "invalid-argument", + `--${name} must be a number.`, + 2 + ); + } + return parsed; +} +function createOptions(args) { + const root = flag(args, "root"); + const catalogId = flag(args, "root-id") ?? "default"; + const configPath = flag(args, "config"); + const cacheDir = flag(args, "cache-dir"); + const managedRoot = flag(args, "managed-root"); + const catalogs = root ? [ + { + id: catalogId, + root, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ] : void 0; + return { + ...configPath ? { configPath } : {}, + ...cacheDir ? { cacheDir } : {}, + ...managedRoot ? { managedRoot } : {}, + ...catalogs ? { catalogs } : {} + }; +} +function lifecycleTarget(args) { + const host = flag(args, "host"); + const supported = /* @__PURE__ */ new Set([ + "codex", + "claude-code", + "antigravity-ide", + "antigravity-cli" + ]); + if (!host || !supported.has(host)) { + throw new StashError( + "invalid-argument", + "--host must be codex, claude-code, antigravity-ide, or antigravity-cli.", + 2 + ); + } + const scope = flag(args, "scope"); + if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + throw new StashError( + "invalid-argument", + "--scope must be user, workspace, or custom.", + 2 + ); + } + const root = flag(args, "host-root"); + const workspace = flag(args, "workspace"); + return { + host, + ...scope ? { scope } : {}, + ...root ? { root } : {}, + ...workspace ? { workspace } : {} + }; +} +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)} +`); +} +function printResolve(result) { + if (result.status === "no-match") { + process.stdout.write( + result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. +` : "No matching skills.\n" + ); + return; + } + if (result.status !== "ok" && result.status !== "ambiguous-exact") { + process.stdout.write(`Stash: ${result.status} +`); + return; + } + process.stdout.write( + `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" + ); + let currentScope = ""; + for (const match of result.matches) { + const group = match.group ?? "(ungrouped)"; + const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; + const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; + if (scope !== currentScope) { + currentScope = scope; + process.stdout.write(` +${scope} +`); + } + const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; + process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} +`); + process.stdout.write(` ref: ${match.ref} +`); + } + if (result.page.nextCursor) { + process.stdout.write(` +next_cursor: ${result.page.nextCursor} +`); + } +} +function printLifecycle(result) { + process.stdout.write( + `${result.name}: ${result.status} (${result.treeHash}) +skill_id: ${result.skillId} +${result.managedPath} +` + ); + if (result.deployment) { + process.stdout.write(`deployment: ${result.deployment.path} +`); + } + if (result.reloadRequired) { + process.stdout.write("Reload or restart the host before relying on discovery changes.\n"); + } + if (result.warning) { + process.stdout.write(`warning: ${result.warning} +`); + } +} +function usage() { + return `Stash \u2014 on-demand search for local Agent Skills + +Usage: + stash exact [--source ] [--group ] [--catalog ] [--json] + stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] + stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] stash read [--resource ] [--format content|path|json] stash index [--catalog ] [--json] stash doctor [--catalog ] [--json] + stash install [--source-url ] [--revision ] [--json] + stash archive --host [--scope user] [--json] + stash activate --host [--scope user] [--json] + stash deactivate --host [--scope user] [--json] + stash status [name] [--json] Configuration: --config Override STASH_CONFIG/platform config. --root Use one catalog without a config file. --root-id Catalog id used with --root (default: default). --cache-dir Override STASH_CACHE_DIR/platform cache. + --managed-root Override STASH_MANAGED_HOME/platform managed store. + +Lifecycle targeting: + --host codex, claude-code, antigravity-ide, or antigravity-cli. + --scope user or custom (default: user; workspace is rejected). + --host-root Explicit discovery root; implies a custom location. Result pagination never caps the total relevant result set. +Lifecycle commands manage only the Stash-owned store and explicitly selected +standalone skills. They never mutate external catalogs, plugins, or host settings. `; } async function main() { @@ -9403,6 +11123,98 @@ async function main() { } return; } + case "install": + case "import": + case "add": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + `${args.command} requires a local skill directory.`, + 2 + ); + } + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(source)) { + throw new StashError( + "remote-install-unsupported", + "Remote installation is not supported in this release. Stage the skill locally, then install that directory.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.install({ + source, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "archive": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + "archive requires a standalone skill directory or a name with --host.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.archive({ + source, + target, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "activate": + case "deactivate": { + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + `${args.command} requires a managed skill name.`, + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const result = args.command === "activate" ? await lifecycle.activate({ name, target }) : await lifecycle.deactivate({ name, target }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "status": { + const name = args.positionals.join(" ").trim(); + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.status(name ? { name } : {}); + if (json) { + printJson(result); + } else if (result.status === "not-found") { + process.stdout.write(`No managed skills at ${result.managedRoot}. +`); + } else { + for (const skill of result.skills) { + process.stdout.write( + `${skill.name}: store=${skill.store.state}/${skill.store.integrity}, deployments=${skill.deployments.length} +` + ); + for (const deployment of skill.deployments) { + process.stdout.write( + ` - ${deployment.host}/${deployment.scope}: ${deployment.state} (${deployment.path}) +` + ); + } + } + } + return; + } default: throw new StashError( "invalid-argument", diff --git a/adapters/antigravity/cli/skills/references/CLI-CONTRACT.md b/adapters/antigravity/cli/skills/references/CLI-CONTRACT.md index 93becdb..0ea0ea3 100644 --- a/adapters/antigravity/cli/skills/references/CLI-CONTRACT.md +++ b/adapters/antigravity/cli/skills/references/CLI-CONTRACT.md @@ -41,6 +41,44 @@ Source IDs and display names are searchable evidence. Prefer `--source` when the Use `--expected-hash` when a workflow must guarantee that the skill selected during resolve is the skill read later. +## Lifecycle contract + +Lifecycle commands use a Stash-owned managed root. They never write to an +external catalog. + +- `install`/`import`/`add`: copy a local skill snapshot into the inactive + managed store and preserve the source. +- `archive`: store and verify an explicitly selected standalone skill, then + remove that source from its discovery path. +- `activate`: copy a managed skill to a host discovery root and record + `status: deployed`. +- `deactivate`: remove only a recorded deployment whose tree hash still + matches. +- `status`: report orthogonal store, integrity, deployment, ownership, and host + observation fields without claiming a host override is enabled. + +`deployed` means present at a documented or explicit discovery root. Check +`reloadRequired` and `warning`. Plugin skills, untracked paths, drifted trees, +symlinks/junctions, overwrites, workspace targets, and all Antigravity CLI +flat-Markdown deployment are rejected. Every managed record has a stable +`skillId`; each deployment has its own ID plus the same `skillId`, Stash +ownership, target ID, and expected tree hash. + +When default resolution includes the managed catalog, `relatedCopies` lists +hash-matching preserved sources and Stash-owned deployments that were folded +into the managed canonical result. Catalog-scoped resolution still returns its +own record, raw refs remain readable, and drifted copies remain separate with a +warning. + +Lifecycle lock metadata is atomically published. A proven-dead owner may be +reclaimed under a single-reclaimer guard; live or malformed ownership fails +closed. If a crashed reclaimer leaves the guard behind, follow +the repository maintenance procedure: stop lifecycle commands, verify both the +recorded PID and all Stash processes are absent, back up `.stash`, move the +guard to an external quarantine, trigger journal preflight with an idempotent +mutation, and verify `status`. Never delete a live/malformed main lock or edit a +journal. + ## Exit codes - `0`: command completed, including a normal `no-match`. diff --git a/adapters/antigravity/cli/skills/references/CONFIGURATION.md b/adapters/antigravity/cli/skills/references/CONFIGURATION.md index 813657e..ddf7af5 100644 --- a/adapters/antigravity/cli/skills/references/CONFIGURATION.md +++ b/adapters/antigravity/cli/skills/references/CONFIGURATION.md @@ -12,6 +12,7 @@ Example: ```yaml version: 1 +managedRoot: "D:/skills/stash-managed" catalogs: - id: personal root: "D:/skills/stash" @@ -37,4 +38,22 @@ Alternatives: Run `stash doctor --json`, then `stash index --json` after configuring a catalog. -The router never edits the configured root. Cache data is stored in the platform cache directory or `STASH_CACHE_DIR`. +No configuration file is required when only the managed inactive store is +used. Its default locations are: + +- Windows: `%LOCALAPPDATA%\stash\managed` +- macOS: `~/Library/Application Support/stash/managed` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/stash/managed` + +Override it with `managedRoot`, `STASH_MANAGED_HOME`, or `--managed-root`. +The managed store is automatically included as catalog id `managed` after it +exists. The router never edits any external configured catalog. Cache data is +stored in the platform cache directory or `STASH_CACHE_DIR`. + +Catalog registration never grants lifecycle write authority. `install` may +read a selected skill inside a configured catalog and preserves its source. +When the managed store is part of the same resolve operation, hash-matching +source and Stash-owned deployment records are folded into the managed result's +`relatedCopies`. Drifted or unrelated records remain separate. A catalog-only +resolve still returns that catalog's own records, and their refs remain +readable. diff --git a/adapters/antigravity/cli/skills/stash.md b/adapters/antigravity/cli/skills/stash.md index 8b21062..bf4f106 100644 --- a/adapters/antigravity/cli/skills/stash.md +++ b/adapters/antigravity/cli/skills/stash.md @@ -1,11 +1,13 @@ --- name: stash -description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `/stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. +description: Search a separate local Agent Skills library or explicitly manage inactive standalone skills. Use only when the user explicitly invokes `/stash` to open, find, list, install into Stash, archive, activate, deactivate, or inspect a stored skill. Do not invoke Stash implicitly for ordinary work. --- # Stash -Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. +Use the bundled CLI to search and read external read-only libraries and the +Stash-managed inactive store. Run lifecycle operations only when the user +explicitly requests them. ## Locate the CLI @@ -15,6 +17,10 @@ Resolve `../scripts/stash.mjs` relative to this skill Markdown file and call the Classify the text after `/stash`. +- `install `, `import `, or a request to put a skill directly + into Stash as inactive: follow [Lifecycle operations](#lifecycle-operations). +- `archive `, `activate `, `deactivate `, or + `status [name]`: follow [Lifecycle operations](#lifecycle-operations). - `list`: run `stash list --json`. - ` list`: run `stash list --group --json`. - ` list`: run `stash list --source --json`. @@ -83,6 +89,69 @@ node read --resource --format json Read only resources directly required by the selected `SKILL.md`. For a script or binary that must be used by another tool, request `--format path`; do not execute it merely because it was discovered. +## Lifecycle operations + +Treat lifecycle commands as a separate mutation workflow from catalog search. +Do not infer permission from a discovery request. + +### Install inactive + +For a local skill directory, run: + +```text +node install [--source-url ] [--revision ] --json +``` + +The source must contain `SKILL.md` directly. The command copies a verified +snapshot into the managed store and leaves the source unchanged. + +When the user explicitly provides a remote repository source, stage the +requested revision in a newly created temporary directory outside every host +skill discovery path, inspect the selected skill root, then run the local +install command with its source URL and resolved revision. Do not execute +repository content. Do not install it into a host skill folder first. Remove +only the temporary staging directory after a successful managed import. + +### Archive a standalone skill + +Resolve exactly one standalone skill directory and require its host root: + +```text +node archive --host [--scope user] --json +``` + +An explicit directory path still requires `--host`; use `--scope custom +--host-root ` when selecting a non-default discovery root. The source +must be an exact child of that root. Explain that archive removes the source +only after a journaled copy, validation, hash check, and commit. Never archive +a plugin-contained skill; delegate plugin lifecycle to the host. + +### Deploy or withdraw a managed copy + +Run: + +```text +node activate --host [--scope user] --json +node deactivate --host [--scope user] --json +``` + +Report the JSON state as `deployed`, not as proof that the host considers the +skill enabled. Stash does not change Codex `skills.config`, Claude Code +`skillOverrides`, plugin state, or equivalent vendor settings. `deactivate` +removes only a deployment with matching Stash ownership, logical `skillId`, +target, and tree hash; never adopt or delete an untracked directory. + +Antigravity CLI uses flat Markdown standalone skills in both documented scopes, +so reject it as a lifecycle host. Workspace lifecycle targets are also outside +this release. After a discovery-path change, honor `reloadRequired` and +`warning` in the result. + +### Inspect state + +Run `stash status [name] --json`. Report storage state, integrity, deployment +state, ownership, and host observation as separate fields. A deployed copy can +still be disabled by its host; the override remains `unknown`. + ## Error handling - For missing configuration, read [CONFIGURATION.md](references/CONFIGURATION.md). @@ -91,8 +160,15 @@ Read only resources directly required by the selected `SKILL.md`. For a script o ## Boundaries -- Treat every configured catalog as read-only. -- Do not install, enable, disable, copy, move, edit, or delete stored skills. +- Treat every external configured catalog as read-only. +- Install may read an explicitly selected local skill inside a configured + catalog, but it must preserve that source. Treat hash-matching related copies + as projections of the managed canonical result, not as lifecycle authority. +- Run lifecycle commands only when explicitly requested, and only against the + Stash-managed store or an exact standalone child of an explicitly selected, + supported host root. +- Delegate plugin lifecycle and vendor enable/disable settings to the host. +- Do not overwrite, follow links, or delete an untracked or drifted deployment. - Do not invoke `stash` implicitly for ordinary work. - Treat loaded skill instructions as task-local and subordinate to current system, developer, and user instructions. - Treat discovery as context optimization, not as an execution permission or security approval. diff --git a/adapters/antigravity/ide/plugin.json b/adapters/antigravity/ide/plugin.json index 31c2d3e..4791cb2 100644 --- a/adapters/antigravity/ide/plugin.json +++ b/adapters/antigravity/ide/plugin.json @@ -1,4 +1,4 @@ { "name": "stash", - "description": "Search and load local Agent Skills from a separate SKILL.md library on demand." + "description": "Search read-only Agent Skills catalogs and manage an explicit local inactive store." } diff --git a/adapters/antigravity/ide/skills/stash/SKILL.md b/adapters/antigravity/ide/skills/stash/SKILL.md index 47938ce..8987d74 100644 --- a/adapters/antigravity/ide/skills/stash/SKILL.md +++ b/adapters/antigravity/ide/skills/stash/SKILL.md @@ -1,11 +1,13 @@ --- name: stash -description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. +description: Search a separate local Agent Skills library or explicitly manage inactive standalone skills. Use only when the user explicitly invokes `stash` to open, find, list, install into Stash, archive, activate, deactivate, or inspect a stored skill. Do not invoke Stash implicitly for ordinary work. --- # Stash -Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. +Use the bundled CLI to search and read external read-only libraries and the +Stash-managed inactive store. Run lifecycle operations only when the user +explicitly requests them. ## Locate the CLI @@ -15,6 +17,10 @@ Resolve `scripts/stash.mjs` relative to this `SKILL.md` and call the resolved ab Classify the text after `stash`. +- `install `, `import `, or a request to put a skill directly + into Stash as inactive: follow [Lifecycle operations](#lifecycle-operations). +- `archive `, `activate `, `deactivate `, or + `status [name]`: follow [Lifecycle operations](#lifecycle-operations). - `list`: run `stash list --json`. - ` list`: run `stash list --group --json`. - ` list`: run `stash list --source --json`. @@ -83,6 +89,69 @@ node read --resource --format json Read only resources directly required by the selected `SKILL.md`. For a script or binary that must be used by another tool, request `--format path`; do not execute it merely because it was discovered. +## Lifecycle operations + +Treat lifecycle commands as a separate mutation workflow from catalog search. +Do not infer permission from a discovery request. + +### Install inactive + +For a local skill directory, run: + +```text +node install [--source-url ] [--revision ] --json +``` + +The source must contain `SKILL.md` directly. The command copies a verified +snapshot into the managed store and leaves the source unchanged. + +When the user explicitly provides a remote repository source, stage the +requested revision in a newly created temporary directory outside every host +skill discovery path, inspect the selected skill root, then run the local +install command with its source URL and resolved revision. Do not execute +repository content. Do not install it into a host skill folder first. Remove +only the temporary staging directory after a successful managed import. + +### Archive a standalone skill + +Resolve exactly one standalone skill directory and require its host root: + +```text +node archive --host [--scope user] --json +``` + +An explicit directory path still requires `--host`; use `--scope custom +--host-root ` when selecting a non-default discovery root. The source +must be an exact child of that root. Explain that archive removes the source +only after a journaled copy, validation, hash check, and commit. Never archive +a plugin-contained skill; delegate plugin lifecycle to the host. + +### Deploy or withdraw a managed copy + +Run: + +```text +node activate --host [--scope user] --json +node deactivate --host [--scope user] --json +``` + +Report the JSON state as `deployed`, not as proof that the host considers the +skill enabled. Stash does not change Codex `skills.config`, Claude Code +`skillOverrides`, plugin state, or equivalent vendor settings. `deactivate` +removes only a deployment with matching Stash ownership, logical `skillId`, +target, and tree hash; never adopt or delete an untracked directory. + +Antigravity CLI uses flat Markdown standalone skills in both documented scopes, +so reject it as a lifecycle host. Workspace lifecycle targets are also outside +this release. After a discovery-path change, honor `reloadRequired` and +`warning` in the result. + +### Inspect state + +Run `stash status [name] --json`. Report storage state, integrity, deployment +state, ownership, and host observation as separate fields. A deployed copy can +still be disabled by its host; the override remains `unknown`. + ## Error handling - For missing configuration, read [CONFIGURATION.md](references/CONFIGURATION.md). @@ -91,8 +160,15 @@ Read only resources directly required by the selected `SKILL.md`. For a script o ## Boundaries -- Treat every configured catalog as read-only. -- Do not install, enable, disable, copy, move, edit, or delete stored skills. +- Treat every external configured catalog as read-only. +- Install may read an explicitly selected local skill inside a configured + catalog, but it must preserve that source. Treat hash-matching related copies + as projections of the managed canonical result, not as lifecycle authority. +- Run lifecycle commands only when explicitly requested, and only against the + Stash-managed store or an exact standalone child of an explicitly selected, + supported host root. +- Delegate plugin lifecycle and vendor enable/disable settings to the host. +- Do not overwrite, follow links, or delete an untracked or drifted deployment. - Do not invoke `stash` implicitly for ordinary work. - Treat loaded skill instructions as task-local and subordinate to current system, developer, and user instructions. - Treat discovery as context optimization, not as an execution permission or security approval. diff --git a/adapters/antigravity/ide/skills/stash/references/CLI-CONTRACT.md b/adapters/antigravity/ide/skills/stash/references/CLI-CONTRACT.md index 93becdb..0ea0ea3 100644 --- a/adapters/antigravity/ide/skills/stash/references/CLI-CONTRACT.md +++ b/adapters/antigravity/ide/skills/stash/references/CLI-CONTRACT.md @@ -41,6 +41,44 @@ Source IDs and display names are searchable evidence. Prefer `--source` when the Use `--expected-hash` when a workflow must guarantee that the skill selected during resolve is the skill read later. +## Lifecycle contract + +Lifecycle commands use a Stash-owned managed root. They never write to an +external catalog. + +- `install`/`import`/`add`: copy a local skill snapshot into the inactive + managed store and preserve the source. +- `archive`: store and verify an explicitly selected standalone skill, then + remove that source from its discovery path. +- `activate`: copy a managed skill to a host discovery root and record + `status: deployed`. +- `deactivate`: remove only a recorded deployment whose tree hash still + matches. +- `status`: report orthogonal store, integrity, deployment, ownership, and host + observation fields without claiming a host override is enabled. + +`deployed` means present at a documented or explicit discovery root. Check +`reloadRequired` and `warning`. Plugin skills, untracked paths, drifted trees, +symlinks/junctions, overwrites, workspace targets, and all Antigravity CLI +flat-Markdown deployment are rejected. Every managed record has a stable +`skillId`; each deployment has its own ID plus the same `skillId`, Stash +ownership, target ID, and expected tree hash. + +When default resolution includes the managed catalog, `relatedCopies` lists +hash-matching preserved sources and Stash-owned deployments that were folded +into the managed canonical result. Catalog-scoped resolution still returns its +own record, raw refs remain readable, and drifted copies remain separate with a +warning. + +Lifecycle lock metadata is atomically published. A proven-dead owner may be +reclaimed under a single-reclaimer guard; live or malformed ownership fails +closed. If a crashed reclaimer leaves the guard behind, follow +the repository maintenance procedure: stop lifecycle commands, verify both the +recorded PID and all Stash processes are absent, back up `.stash`, move the +guard to an external quarantine, trigger journal preflight with an idempotent +mutation, and verify `status`. Never delete a live/malformed main lock or edit a +journal. + ## Exit codes - `0`: command completed, including a normal `no-match`. diff --git a/adapters/antigravity/ide/skills/stash/references/CONFIGURATION.md b/adapters/antigravity/ide/skills/stash/references/CONFIGURATION.md index 813657e..ddf7af5 100644 --- a/adapters/antigravity/ide/skills/stash/references/CONFIGURATION.md +++ b/adapters/antigravity/ide/skills/stash/references/CONFIGURATION.md @@ -12,6 +12,7 @@ Example: ```yaml version: 1 +managedRoot: "D:/skills/stash-managed" catalogs: - id: personal root: "D:/skills/stash" @@ -37,4 +38,22 @@ Alternatives: Run `stash doctor --json`, then `stash index --json` after configuring a catalog. -The router never edits the configured root. Cache data is stored in the platform cache directory or `STASH_CACHE_DIR`. +No configuration file is required when only the managed inactive store is +used. Its default locations are: + +- Windows: `%LOCALAPPDATA%\stash\managed` +- macOS: `~/Library/Application Support/stash/managed` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/stash/managed` + +Override it with `managedRoot`, `STASH_MANAGED_HOME`, or `--managed-root`. +The managed store is automatically included as catalog id `managed` after it +exists. The router never edits any external configured catalog. Cache data is +stored in the platform cache directory or `STASH_CACHE_DIR`. + +Catalog registration never grants lifecycle write authority. `install` may +read a selected skill inside a configured catalog and preserves its source. +When the managed store is part of the same resolve operation, hash-matching +source and Stash-owned deployment records are folded into the managed result's +`relatedCopies`. Drifted or unrelated records remain separate. A catalog-only +resolve still returns that catalog's own records, and their refs remain +readable. diff --git a/adapters/antigravity/ide/skills/stash/scripts/stash.mjs b/adapters/antigravity/ide/skills/stash/scripts/stash.mjs index 7375d61..c1d2319 100644 --- a/adapters/antigravity/ide/skills/stash/scripts/stash.mjs +++ b/adapters/antigravity/ide/skills/stash/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path5) { - const ctrl = callVisitor(key, node, visitor, path5); + function visit_(key, node, visitor, path7) { + const ctrl = callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visit_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visit_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path5); + const ci = visit_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = visit_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = visit_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path5); + const cv = visit_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path5) { - const ctrl = await callVisitor(key, node, visitor, path5); + async function visitAsync_(key, node, visitor, path7) { + const ctrl = await callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visitAsync_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visitAsync_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path5); + const ci = await visitAsync_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path5); + const cv = await visitAsync_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path5) { + function callVisitor(key, node, visitor, path7) { if (typeof visitor === "function") - return visitor(key, node, path5); + return visitor(key, node, path7); if (identity.isMap(node)) - return visitor.Map?.(key, node, path5); + return visitor.Map?.(key, node, path7); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path5); + return visitor.Seq?.(key, node, path7); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path5); + return visitor.Pair?.(key, node, path7); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path5); + return visitor.Scalar?.(key, node, path7); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path5); + return visitor.Alias?.(key, node, path7); return void 0; } - function replaceNode(key, path5, node) { - const parent = path5[path5.length - 1]; + function replaceNode(key, path7, node) { + const parent = path7[path7.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path5, value) { + function collectionFromPath(schema, path7, value) { let v = value; - for (let i = path5.length - 1; i >= 0; --i) { - const k = path5[i]; + for (let i = path7.length - 1; i >= 0; --i) { + const k = path7[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path5) => path5 == null || typeof path5 === "object" && !!path5[Symbol.iterator]().next().done; + var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path5, value) { - if (isEmptyPath(path5)) + addIn(path7, value) { + if (isEmptyPath(path7)) this.add(value); else { - const [key, ...rest] = path5; + const [key, ...rest] = path7; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - const [key, ...rest] = path5; + deleteIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - const [key, ...rest] = path5; + getIn(path7, keepScalar) { + const [key, ...rest] = path7; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path5) { - const [key, ...rest] = path5; + hasIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - const [key, ...rest] = path5; + setIn(path7, value) { + const [key, ...rest] = path7; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path5, value) { + addIn(path7, value) { if (assertCollection(this.contents)) - this.contents.addIn(path5, value); + this.contents.addIn(path7, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - if (Collection.isEmptyPath(path5)) { + deleteIn(path7) { + if (Collection.isEmptyPath(path7)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path5) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - if (Collection.isEmptyPath(path5)) + getIn(path7, keepScalar) { + if (Collection.isEmptyPath(path7)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path5, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path5) { - if (Collection.isEmptyPath(path5)) + hasIn(path7) { + if (Collection.isEmptyPath(path7)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path5) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - if (Collection.isEmptyPath(path5)) { + setIn(path7, value) { + if (Collection.isEmptyPath(path7)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path5), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path5, value); + this.contents.setIn(path7, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path5) => { + visit.itemAtPath = (cst, path7) => { let item = cst; - for (const [field, index] of path5) { + for (const [field, index] of path7) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path5) => { - const parent = visit.itemAtPath(cst, path5.slice(0, -1)); - const field = path5[path5.length - 1][0]; + visit.parentCollection = (cst, path7) => { + const parent = visit.itemAtPath(cst, path7.slice(0, -1)); + const field = path7[path7.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path5, item, visitor) { - let ctrl = visitor(item, path5); + function _visit(path7, item, visitor) { + let ctrl = visitor(item, path7); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path5.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path5); + ctrl = ctrl(item, path7); } } - return typeof ctrl === "function" ? ctrl(item, path5) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; } exports.visit = visit; } @@ -7263,7 +7263,7 @@ var require_public_api = __commonJS({ } return doc; } - function parse3(src, reviver, options) { + function parse4(src, reviver, options) { let _reviver = void 0; if (typeof reviver === "function") { _reviver = reviver; @@ -7304,7 +7304,7 @@ var require_public_api = __commonJS({ return value.toString(options); return new Document.Document(value, _replacer, options).toString(options); } - exports.parse = parse3; + exports.parse = parse4; exports.parseAllDocuments = parseAllDocuments; exports.parseDocument = parseDocument; exports.stringify = stringify; @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile3, realpath as realpath2, stat as stat2 } from "node:fs/promises"; -import path4 from "node:path"; +import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; +import path5 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7538,6 +7538,33 @@ function platformCachePath() { "stash" ); } +function platformManagedPath() { + if (process.env.STASH_MANAGED_HOME) { + return path.resolve(process.env.STASH_MANAGED_HOME); + } + const currentPlatform = platform(); + if (currentPlatform === "win32") { + return path.join( + process.env.LOCALAPPDATA ?? path.join(homedir(), "AppData", "Local"), + "stash", + "managed" + ); + } + if (currentPlatform === "darwin") { + return path.join( + homedir(), + "Library", + "Application Support", + "stash", + "managed" + ); + } + return path.join( + process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share"), + "stash", + "managed" + ); +} function safeCatalogSegment(value) { return value.replace(/[^a-zA-Z0-9._-]/gu, "_"); } @@ -7668,16 +7695,21 @@ function parseDefaults(value, overrides) { async function loadConfiguration(options) { const cacheDir = path2.resolve(options.cacheDir ?? platformCachePath()); if (options.catalogs) { - const catalogs = options.catalogs.map((catalog) => ({ + let catalogs2 = options.catalogs.map((catalog) => ({ ...catalog, root: path2.resolve(catalog.root) })); - validateUniqueCatalogIds(catalogs); + const managedRoot2 = options.managedRoot ? path2.resolve(options.managedRoot) : void 0; + if (managedRoot2) { + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + } + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + ...managedRoot2 ? { managedRoot: managedRoot2 } : {} }, cacheDir }; @@ -7694,23 +7726,29 @@ async function loadConfiguration(options) { 5 ); } - if (!Array.isArray(parsed.catalogs)) { + if (parsed.catalogs !== void 0 && !Array.isArray(parsed.catalogs)) { throw new StashError( "invalid-config", - `Config "${configPath}" must define a catalogs array.`, + `Config "${configPath}" catalogs must be an array.`, 2 ); } const baseDir = path2.dirname(configPath); - const catalogs = parsed.catalogs.map( + let catalogs2 = (Array.isArray(parsed.catalogs) ? parsed.catalogs : []).map( (catalog, index) => parseCatalog(catalog, baseDir, index) ); - validateUniqueCatalogIds(catalogs); + const configuredManagedRoot = typeof parsed.managedRoot === "string" && parsed.managedRoot.trim() ? path2.resolve(baseDir, parsed.managedRoot) : void 0; + const managedRoot2 = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? configuredManagedRoot ?? platformManagedPath() + ); + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(parsed.defaults, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(parsed.defaults, options.defaults), + managedRoot: managedRoot2 }, cacheDir, configPath @@ -7728,29 +7766,92 @@ async function loadConfiguration(options) { ); } } + const managedRoot = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ); if (process.env.STASH_HOME) { + const catalogs2 = await includeManagedCatalog( + [ + { + id: "default", + root: path2.resolve(process.env.STASH_HOME), + enabled: true, + trust: "unreviewed", + followSymlinks: false + } + ], + managedRoot + ); return { configuration: { version: 1, - catalogs: [ - { - id: "default", - root: path2.resolve(process.env.STASH_HOME), - enabled: true, - trust: "unreviewed", - followSymlinks: false - } - ], - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + managedRoot }, cacheDir }; } - throw new StashError( - "config-not-found", - `Stash config was not found at "${configPath}". Set STASH_CONFIG, STASH_HOME, or pass --config.`, - 2 - ); + if (explicitConfig) { + throw new StashError( + "config-not-found", + `Stash config was not found at "${configPath}".`, + 2 + ); + } + const catalogs = await includeManagedCatalog([], managedRoot); + return { + configuration: { + version: 1, + catalogs, + defaults: parseDefaults(void 0, options.defaults), + managedRoot + }, + cacheDir + }; +} +async function includeManagedCatalog(catalogs, managedRoot) { + if (catalogs.some((catalog) => catalog.id === "managed")) { + throw new StashError( + "invalid-config", + 'Catalog id "managed" is reserved for the Stash-managed store.', + 2 + ); + } + const normalizedManagedRoot = path2.resolve(managedRoot); + if (catalogs.some((catalog) => { + const normalizedCatalogRoot = path2.resolve(catalog.root); + return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; + })) { + return catalogs; + } + try { + const info = await stat(managedRoot); + if (!info.isDirectory()) { + throw new StashError( + "invalid-config", + `Managed root is not a directory: "${managedRoot}".`, + 2 + ); + } + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return catalogs; + } + throw error; + } + return [ + ...catalogs, + { + id: "managed", + root: managedRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ]; } function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); @@ -7775,7 +7876,7 @@ import { readdir, realpath, rename, - stat, + stat as stat2, unlink, writeFile } from "node:fs/promises"; @@ -7878,7 +7979,7 @@ function parseSidecar(source, catalog) { }; } async function readBounded(filePath, maxBytes) { - const info = await stat(filePath); + const info = await stat2(filePath); if (info.size > maxBytes) { throw new Error(`File exceeds ${maxBytes} bytes.`); } @@ -7889,7 +7990,7 @@ async function loadManifest(root, catalog, warnings) { let input = {}; let fingerprintPart = ""; try { - const manifestInfo = await stat(manifestPath); + const manifestInfo = await stat2(manifestPath); input = asObject2((0, import_yaml2.parse)(await readBounded(manifestPath, MAX_SIDECAR_BYTES))); fingerprintPart = [ "stash.catalog.yaml", @@ -7961,7 +8062,7 @@ async function discoverCandidates(catalog) { 4 ); } - const rootInfo = await stat(root); + const rootInfo = await stat2(root); if (!rootInfo.isDirectory()) { throw new StashError( "catalog-unavailable", @@ -7987,13 +8088,13 @@ async function discoverCandidates(catalog) { ); if (skillEntry) { const skillFile = path3.join(directory, skillEntry.name); - const info = await stat(skillFile); + const info = await stat2(skillFile); const sidecarPath = path3.join(directory, "stash.meta.yaml"); let sidecarFile; let sidecarModifiedMs; let sidecarSize; try { - const sidecarInfo = await stat(sidecarPath); + const sidecarInfo = await stat2(sidecarPath); if (sidecarInfo.isFile()) { sidecarFile = sidecarPath; sidecarModifiedMs = sidecarInfo.mtimeMs; @@ -8038,7 +8139,7 @@ async function discoverCandidates(catalog) { }); continue; } - const targetInfo = await stat(target); + const targetInfo = await stat2(target); if (targetInfo.isDirectory()) { await walk(target, depth + 1); } @@ -8234,7 +8335,7 @@ async function acquireLock(lockPath) { throw error; } try { - const lockInfo = await stat(lockPath); + const lockInfo = await stat2(lockPath); if (Date.now() - lockInfo.mtimeMs > 3e4) { await unlink(lockPath); continue; @@ -8254,6 +8355,225 @@ async function acquireLock(lockPath) { } } +// src/internal/managed-projection.ts +import { + lstat, + readFile as readFile3, + readdir as readdir2, + realpath as realpath2, + stat as stat3 +} from "node:fs/promises"; +import { platform as platform2 } from "node:os"; +import path4 from "node:path"; +var MAX_FILES = 1e4; +var MAX_TOTAL_BYTES = 256 * 1024 * 1024; +function pathIdentity(value) { + const normalized = path4.resolve(value).normalize("NFKC"); + return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function validRecord(value) { + if (!value || typeof value !== "object") { + return false; + } + const record = value; + return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); +} +async function treeHash(rootInput) { + try { + const rootInfo = await lstat(rootInput); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + return void 0; + } + const root = await realpath2(rootInput); + const entries = []; + let files = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new Error("linked tree"); + } + if (before.isDirectory()) { + const canonical = await realpath2(childPath); + if (!isPathInside(root, canonical)) { + throw new Error("tree escape"); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new Error("special file"); + } + files += 1; + totalBytes += before.size; + if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new Error("tree too large"); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new Error("tree changed"); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return sha256(fingerprint); + } catch { + return void 0; + } +} +function relatedCopy(target, record) { + if (target.kind === "deployment" && target.deployment) { + return { + kind: "deployment", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId, + targetId: target.deployment.targetId, + host: target.deployment.host, + scope: target.deployment.scope + }; + } + return { + kind: "source", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId + }; +} +async function projectManagedCopies(sourceIndexes, managedRoot) { + if (!managedRoot || !sourceIndexes.some((index) => index.catalogId === "managed")) { + return { indexes: sourceIndexes, fingerprintPart: "" }; + } + const indexes = sourceIndexes.map((index) => ({ + ...index, + records: index.records.map((record) => ({ ...record })), + warnings: [...index.warnings] + })); + const managedIndex = indexes.find((index) => index.catalogId === "managed"); + if (!managedIndex) { + return { indexes, fingerprintPart: "" }; + } + let recordFiles; + const recordsRoot = path4.join(managedRoot, ".stash", "records"); + try { + recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + } catch { + return { indexes, fingerprintPart: "" }; + } + const targets = /* @__PURE__ */ new Map(); + const managedRecords = /* @__PURE__ */ new Map(); + for (const file of recordFiles) { + try { + const parsed = JSON.parse( + await readFile3(path4.join(recordsRoot, file), "utf8") + ); + if (!validRecord(parsed)) { + throw new Error("invalid record"); + } + managedRecords.set(parsed.name, parsed); + targets.set(pathIdentity(parsed.source.location), { + kind: "source", + record: parsed, + expectedTreeHash: parsed.treeHash + }); + for (const deployment of parsed.deployments) { + if (deployment.ownership === "stash" && deployment.skillId === parsed.skillId) { + targets.set(pathIdentity(deployment.path), { + kind: "deployment", + record: parsed, + expectedTreeHash: deployment.treeHash, + deployment + }); + } + } + } catch { + managedIndex.warnings.push({ + code: "invalid-lifecycle-record", + message: `Ignored invalid lifecycle projection record "${file}".`, + path: `.stash/records/${file}` + }); + } + } + const canonicalBySkillId = /* @__PURE__ */ new Map(); + for (const record of managedIndex.records) { + const managedRecord = managedRecords.get(record.name); + if (managedRecord) { + record.managedSkillId = managedRecord.skillId; + record.relatedCopies = []; + canonicalBySkillId.set(managedRecord.skillId, record); + } + } + const events = []; + for (const index of indexes) { + if (index.catalogId === "managed") { + continue; + } + const visible = []; + for (const record of index.records) { + const skillRoot = path4.dirname( + path4.resolve(index.root, record.relativeSkillFile) + ); + const target = targets.get(pathIdentity(skillRoot)); + const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; + if (!target || !canonical) { + visible.push(record); + continue; + } + const actualTreeHash = await treeHash(skillRoot); + if (actualTreeHash !== target.expectedTreeHash) { + visible.push(record); + index.warnings.push({ + code: "managed-copy-drift", + message: `Managed-related ${target.kind} "${record.ref}" drifted and remains a separate result.`, + ref: record.ref, + path: record.relativeSkillFile + }); + events.push(`drift:${record.ref}:${actualTreeHash ?? "unavailable"}`); + continue; + } + const related = relatedCopy(target, record); + canonical.relatedCopies = [...canonical.relatedCopies ?? [], related]; + events.push( + `fold:${record.ref}:${target.record.skillId}:${target.kind}:${actualTreeHash}` + ); + } + index.records = visible; + } + for (const record of managedIndex.records) { + if (record.relatedCopies) { + record.relatedCopies.sort( + (left, right) => `${left.kind}:${left.catalogId}:${left.ref}`.localeCompare( + `${right.kind}:${right.catalogId}:${right.ref}`, + "en" + ) + ); + } + } + return { + indexes, + fingerprintPart: sha256(events.sort().join("\n")) + }; +} + // src/internal/search.ts var ROUTING_PROFILE_VERSION = 4; var FIELD_WEIGHTS = { @@ -8583,7 +8903,9 @@ function toListedSkill(record) { compatibility: record.compatibility, trust: record.trust, ...Object.keys(record.source).length > 0 ? { source: record.source } : {}, - contentHash: record.contentHash + contentHash: record.contentHash, + ...record.managedSkillId ? { managedSkillId: record.managedSkillId } : {}, + ...record.relatedCopies && record.relatedCopies.length > 0 ? { relatedCopies: record.relatedCopies } : {} }; } @@ -8623,7 +8945,7 @@ var StashCatalogImplementation = class { this.#cacheDir = cacheDir; this.#now = now; } - async #loadIndexes(catalogIds) { + async #loadIndexes(catalogIds, applyManagedProjection = true) { const selected = this.#selectRegistrations(catalogIds); const indexes = []; for (const catalog of selected) { @@ -8635,12 +8957,18 @@ var StashCatalogImplementation = class { ); indexes.push(loaded.index); } + const projected = applyManagedProjection ? await projectManagedCopies(indexes, this.#configuration.managedRoot) : { indexes, fingerprintPart: "" }; return { - indexes, + indexes: projected.indexes, registrations: selected, - warnings: indexes.flatMap((index) => index.warnings), + warnings: projected.indexes.flatMap((index) => index.warnings), fingerprint: sha256( - indexes.map((index) => `${index.catalogId}:${index.fingerprint}`).sort().join("\n") + [ + ...projected.indexes.map( + (index) => `${index.catalogId}:${index.fingerprint}` + ), + projected.fingerprintPart + ].sort().join("\n") ) }; } @@ -8900,7 +9228,7 @@ var StashCatalogImplementation = class { resource }; } - const loaded = await this.#loadIndexes(); + const loaded = await this.#loadIndexes(void 0, false); let record; let index; for (const candidateIndex of loaded.indexes) { @@ -8929,10 +9257,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path4.resolve(skillRoot, normalizedResource); + const candidatePath = path5.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8944,7 +9272,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath2(candidatePath); + resolvedPath = await realpath3(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8953,7 +9281,7 @@ var StashCatalogImplementation = class { resource }; } - const info = await stat2(resolvedPath); + const info = await stat4(resolvedPath); if (!info.isFile()) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9005,7 +9333,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile3(resolvedPath); + const buffer = await readFile4(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9120,140 +9448,1532 @@ async function createStashCatalog(options = {}) { ); } -// src/cli.ts -function parseArguments(argv) { - const [command, ...rest] = argv; - const positionals = []; - const flags2 = /* @__PURE__ */ new Map(); - for (let index = 0; index < rest.length; index += 1) { - const current = rest[index]; - if (!current) { - continue; - } - if (!current.startsWith("--")) { - positionals.push(current); - continue; - } - const [rawName, inlineValue] = current.slice(2).split("=", 2); - const name = rawName ?? ""; - let value = inlineValue; - if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { - value = rest[index + 1]; - index += 1; - } - const values = flags2.get(name) ?? []; - values.push(value ?? "true"); - flags2.set(name, values); - } - return { ...command ? { command } : {}, positionals, flags: flags2 }; -} -function flag(args, name) { - return args.flags.get(name)?.at(-1); -} -function flags(args, name) { - const values = args.flags.get(name); - return values && values.length > 0 ? values : void 0; +// src/stash-lifecycle.ts +var import_yaml3 = __toESM(require_dist(), 1); +import { randomUUID } from "node:crypto"; +import { + lstat as lstat2, + mkdir as mkdir2, + readFile as readFile5, + readdir as readdir3, + realpath as realpath4, + rename as rename2, + rm, + stat as stat5, + unlink as unlink2, + writeFile as writeFile2 +} from "node:fs/promises"; +import { homedir as homedir2, platform as platform3 } from "node:os"; +import path6 from "node:path"; +var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +var MAX_FILES2 = 1e4; +var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; +var STORE_SCHEMA_VERSION = 1; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +function asObject3(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } -function booleanFlag(args, name) { - return args.flags.has(name) && flag(args, name) !== "false"; +function compatibilityState2(value) { + return value === "supported" || value === "partial" || value === "unsupported" || value === "unknown" ? value : "unknown"; } -function numberFlag(args, name) { - const value = flag(args, name); - if (value === void 0) { - return void 0; +function parseSkillMetadata(snapshot) { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec( + snapshot.skillSource.replace(/^\uFEFF/u, "") + ); + if (!match?.[1]) { + throw new StashError( + "invalid-skill", + "SKILL.md must contain closed YAML frontmatter.", + 3 + ); } - const parsed = Number(value); - if (!Number.isFinite(parsed)) { + const metadata = asObject3((0, import_yaml3.parse)(match[1])); + if (typeof metadata.name !== "string" || metadata.name.length > 64 || !NAME_PATTERN2.test(metadata.name)) { throw new StashError( - "invalid-argument", - `--${name} must be a number.`, - 2 + "invalid-skill", + "SKILL.md name must use 1-64 lowercase letters, digits, or hyphens.", + 3 ); } - return parsed; -} -function createOptions(args) { - const root = flag(args, "root"); - const catalogId = flag(args, "root-id") ?? "default"; - const configPath = flag(args, "config"); - const cacheDir = flag(args, "cache-dir"); - const catalogs = root ? [ - { - id: catalogId, - root, - enabled: true, - trust: "unreviewed", - followSymlinks: false, - compatibility: ["codex", "claude-code", "antigravity"] + if (typeof metadata.description !== "string" || metadata.description.trim() === "" || metadata.description.length > 1024) { + throw new StashError( + "invalid-skill", + "SKILL.md description must be a non-empty string up to 1024 characters.", + 3 + ); + } + let compatibility = {}; + if (snapshot.sidecarSource) { + const sidecar = asObject3((0, import_yaml3.parse)(snapshot.sidecarSource)); + if (sidecar.schemaVersion !== void 0 && sidecar.schemaVersion !== 1) { + throw new StashError( + "invalid-skill", + "stash.meta.yaml must use schemaVersion: 1.", + 3 + ); } - ] : void 0; + compatibility = asObject3(sidecar.compatibility); + } return { - ...configPath ? { configPath } : {}, - ...cacheDir ? { cacheDir } : {}, - ...catalogs ? { catalogs } : {} + name: metadata.name, + compatibility: { + codex: compatibilityState2(compatibility.codex), + "claude-code": compatibilityState2(compatibility["claude-code"]), + antigravity: compatibilityState2(compatibility.antigravity) + } }; } -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)} -`); +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); } -function printResolve(result) { - if (result.status === "no-match") { - process.stdout.write( - result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. -` : "No matching skills.\n" +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot end in a dot or space: "${segment}".`, + 3 ); - return; - } - if (result.status !== "ok" && result.status !== "ambiguous-exact") { - process.stdout.write(`Stash: ${result.status} -`); - return; - } - process.stdout.write( - `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" - ); - let currentScope = ""; - for (const match of result.matches) { - const group = match.group ?? "(ungrouped)"; - const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; - const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; - if (scope !== currentScope) { - currentScope = scope; - process.stdout.write(` -${scope} -`); - } - const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; - process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} -`); - process.stdout.write(` ref: ${match.ref} -`); } - if (result.page.nextCursor) { - process.stdout.write(` -next_cursor: ${result.page.nextCursor} -`); + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot use the reserved name "${segment}".`, + 3 + ); } } -function usage() { - return `Stash \u2014 on-demand search for local Agent Skills - -Usage: - stash exact [--source ] [--group ] [--catalog ] [--json] - stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] - stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] +async function snapshotTree(sourceRoot) { + const rootInput = path6.resolve(sourceRoot); + const rootInfo = await lstat2(rootInput).catch((error) => { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${rootInput}": ${String(error)}`, + 4 + ); + }); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new StashError( + "unsafe-skill-tree", + `Skill root must be a real directory, not a link: "${rootInput}".`, + 3 + ); + } + const root = await realpath4(rootInput); + const entries = []; + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + let skillSource; + let sidecarSource; + async function walk(directory, relativeDirectory) { + const children = await readdir3(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new StashError( + "unsafe-skill-tree", + `Case-insensitive path collision at "${relativePath}".`, + 3 + ); + } + pathKeys.add(key); + const childPath = path6.join(directory, child.name); + const before = await lstat2(childPath); + if (before.isSymbolicLink()) { + throw new StashError( + "unsafe-skill-tree", + `Symlinks and junctions are not allowed: "${relativePath}".`, + 3 + ); + } + if (before.isDirectory()) { + const canonical = await realpath4(childPath); + if (!isPathInside(root, canonical)) { + throw new StashError( + "unsafe-skill-tree", + `Directory escapes the skill root: "${relativePath}".`, + 3 + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new StashError( + "unsafe-skill-tree", + `Only regular files and directories are allowed: "${relativePath}".`, + 3 + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { + throw new StashError( + "skill-too-large", + `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, + 3 + ); + } + const content = await readFile5(childPath); + const after = await stat5(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new StashError( + "source-changed", + `Skill changed while it was being read: "${relativePath}".`, + 4 + ); + } + const contentHash = sha256(content); + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash + }); + if (relativePath === "SKILL.md") { + skillSource = content.toString("utf8"); + } else if (relativePath === "stash.meta.yaml") { + sidecarSource = content.toString("utf8"); + } + } + } + await walk(root, ""); + if (skillSource === void 0) { + throw new StashError( + "invalid-skill", + `Skill root must contain SKILL.md: "${root}".`, + 3 + ); + } + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + skillSource, + ...sidecarSource !== void 0 ? { sidecarSource } : {} + }; +} +async function copySnapshot(snapshot, destination) { + await mkdir2(destination, { recursive: false }); + for (const entry of snapshot.entries) { + const relative = entry.relativePath.split("/").join(path6.sep); + const target = path6.join(destination, relative); + if (entry.kind === "directory") { + await mkdir2(target, { recursive: false }); + continue; + } + const source = path6.join(snapshot.root, relative); + const content = await readFile5(source); + if (sha256(content) !== entry.contentHash) { + throw new StashError( + "source-changed", + `Skill changed while it was being copied: "${entry.relativePath}".`, + 4 + ); + } + await writeFile2(target, content, { flag: "wx" }); + } +} +async function pathType(target) { + try { + const info = await lstat2(target); + if (info.isSymbolicLink()) { + return "link"; + } + if (info.isDirectory()) { + return "directory"; + } + return "other"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return "missing"; + } + throw error; + } +} +function samePath(left, right) { + const normalizedLeft = path6.resolve(left).normalize("NFKC"); + const normalizedRight = path6.resolve(right).normalize("NFKC"); + return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; +} +function pathIdentity2(value) { + const normalized = path6.resolve(value).normalize("NFKC"); + return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function targetIdentity(target) { + return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; +} +function resolveHostRoot(target) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace") { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + if (target.root) { + return { + host: target.host, + scope: target.scope ?? "custom", + root: path6.resolve(target.root) + }; + } + const scope = target.scope ?? "user"; + if (scope === "custom") { + throw new StashError( + "invalid-argument", + "A custom lifecycle target requires root.", + 2 + ); + } + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".gemini", "config", "skills") + }; + } +} +async function isPluginContained(source) { + let current = path6.dirname(source); + for (let depth = 0; depth < 12; depth += 1) { + const markers = [ + path6.join(current, ".claude-plugin", "plugin.json"), + path6.join(current, ".codex-plugin", "plugin.json"), + path6.join(current, "plugin.json") + ]; + for (const marker of markers) { + if (await pathType(marker) !== "missing") { + return true; + } + } + const parent = path6.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return false; +} +var StashLifecycleImplementation = class { + #managedRoot; + #now; + constructor(managedRoot, now) { + this.#managedRoot = path6.resolve(managedRoot); + this.#now = now; + } + #metadataRoot() { + return path6.join(this.#managedRoot, ".stash"); + } + #recordPath(name) { + return path6.join(this.#metadataRoot(), "records", `${name}.json`); + } + async #ensureLayout() { + await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + try { + await writeFile2( + manifestPath, + "schemaVersion: 1\nid: managed\ndefaults:\n trust: unreviewed\n compatibility:\n - codex\n - claude-code\n - antigravity\nskillDiscovery:\n ignore:\n - .stash\n", + { encoding: "utf8", flag: "wx" } + ); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "EEXIST") { + throw error; + } + } + } + #journalPath(operationId) { + return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + } + async #writeJournal(journal) { + const finalPath = this.#journalPath(journal.operationId); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(journal, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #advanceArchiveJournal(journal, stage) { + const next = { ...journal, stage }; + await this.#writeJournal(next); + journal.stage = stage; + } + async #removeIncompleteManaged(journal) { + if (journal.managedExistedBefore) { + return; + } + const record = await this.#readRecord(journal.name); + if (record && record.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed record changed during archive recovery: "${journal.name}".`, + 4 + ); + } + if (await pathType(journal.managedPath) === "directory") { + const snapshot = await snapshotTree(journal.managedPath); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed tree changed during archive recovery: "${journal.managedPath}".`, + 4 + ); + } + await rm(journal.managedPath, { recursive: true, force: false }); + } + await unlink2(this.#recordPath(journal.name)).catch((error) => { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + }); + } + #validateArchiveJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "managed-committed", + "source-tombstoned", + "archive-committed" + ]); + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + const expectedManagedPath = path6.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + `.stash-archive-${journal.name}-` + ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + } + async #recoverArchiveJournal(journal, journalPath) { + const sourceType = await pathType(journal.source); + const tombstoneType = await pathType(journal.tombstone); + if (journal.stage === "archive-committed") { + if (tombstoneType !== "missing") { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone is not a directory: "${journal.tombstone}".`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rm(journal.tombstone, { recursive: true, force: false }); + } + await unlink2(journalPath); + return; + } + const sourceWasMoved = journal.stage === "source-tombstoned" || sourceType === "missing" && tombstoneType !== "missing"; + if (sourceWasMoved) { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive source and tombstone cannot be reconciled for "${journal.name}".`, + 4 + ); + } + if (sourceType !== "missing") { + throw new StashError( + "archive-restore-conflict", + `Archive recovery preserved "${journal.tombstone}" because "${journal.source}" is occupied.`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rename2(journal.tombstone, journal.source); + } + await this.#removeIncompleteManaged(journal); + await unlink2(journalPath); + } + async #recoverJournals() { + const journalRoot = path6.join(this.#metadataRoot(), "journal"); + const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + for (const file of files) { + const journalPath = path6.join(journalRoot, file); + let journal; + try { + journal = JSON.parse(await readFile5(journalPath, "utf8")); + } catch (error) { + throw new StashError( + "invalid-lifecycle-journal", + `Unable to read lifecycle journal "${journalPath}": ${String(error)}`, + 5 + ); + } + this.#validateArchiveJournal(journal, journalPath); + await this.#recoverArchiveJournal(journal, journalPath); + } + } + async #readLockOwner(lockPath) { + let owner; + try { + owner = JSON.parse( + await readFile5(path6.join(lockPath, "owner.json"), "utf8") + ); + } catch (error) { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is missing or malformed at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + if (owner.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(owner.ownerToken) || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.createdAt !== "string") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is invalid at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + return owner; + } + #ownerIsAlive(owner) { + try { + process.kill(owner.pid, 0); + return true; + } catch { + return false; + } + } + async #publishLock(lockPath, owner) { + const temporaryPath = path6.join( + this.#metadataRoot(), + `.lifecycle-lock-${owner.ownerToken}.tmp` + ); + await mkdir2(temporaryPath, { recursive: false }); + try { + await writeFile2( + path6.join(temporaryPath, "owner.json"), + `${JSON.stringify(owner)} +`, + { encoding: "utf8", flag: "wx" } + ); + try { + await rename2(temporaryPath, lockPath); + return true; + } catch (error) { + if (await pathType(lockPath) === "directory") { + return false; + } + throw error; + } + } finally { + await rm(temporaryPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #reclaimDeadLock(lockPath) { + const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + try { + await mkdir2(guardPath, { recursive: false }); + } catch (error) { + if (await pathType(guardPath) === "directory") { + throw new StashError( + "lifecycle-busy", + `Another process is checking stale lifecycle ownership at "${lockPath}". If no Stash process is running, follow the documented reclaim-guard repair procedure.`, + 4 + ); + } + throw error; + } + try { + if (await pathType(lockPath) === "missing") { + return; + } + if (await pathType(lockPath) !== "directory") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock is not a directory at "${lockPath}".`, + 4 + ); + } + const owner = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(owner)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + const reclaimedPath = path6.join( + this.#metadataRoot(), + `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` + ); + await rename2(lockPath, reclaimedPath); + const reclaimedOwner = await this.#readLockOwner(reclaimedPath); + if (reclaimedOwner.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-corrupt", + "Lifecycle lock ownership changed during stale recovery.", + 4 + ); + } + await rm(reclaimedPath, { recursive: true, force: false }); + } finally { + await rm(guardPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #acquireLock(lockPath) { + const owner = { + schemaVersion: 1, + ownerToken: randomUUID(), + pid: process.pid, + createdAt: new Date(this.#now()).toISOString() + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await this.#publishLock(lockPath, owner)) { + return owner; + } + const existing = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(existing)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + await this.#reclaimDeadLock(lockPath); + } + throw new StashError( + "lifecycle-busy", + `Lifecycle ownership changed while acquiring "${lockPath}".`, + 4 + ); + } + async #releaseLock(lockPath, owner) { + const current = await this.#readLockOwner(lockPath); + if (current.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-lost", + `Lifecycle lock ownership changed at "${lockPath}".`, + 4 + ); + } + await rm(lockPath, { recursive: true, force: false }); + } + async #withLock(operation) { + await this.#ensureLayout(); + const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const owner = await this.#acquireLock(lockPath); + try { + await this.#recoverJournals(); + return await operation(); + } finally { + await this.#releaseLock(lockPath, owner); + } + } + async #readRecord(name) { + const recordPath = this.#recordPath(name); + try { + const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + )) { + throw new Error("invalid lifecycle record shape"); + } + return parsed; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return void 0; + } + throw new StashError( + "invalid-lifecycle-record", + `Unable to read lifecycle record "${recordPath}": ${String(error)}`, + 5 + ); + } + } + async #writeRecord(record) { + const finalPath = this.#recordPath(record.name); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(record, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #assertSourceBoundary(source) { + const canonicalSource = await realpath4(source); + const canonicalManaged = await realpath4(this.#managedRoot); + if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { + throw new StashError( + "unsafe-source", + "A lifecycle source cannot be inside, contain, or equal the managed root.", + 3 + ); + } + } + async #canonicalHostRoot(root, create, allowMissing = false) { + let type = await pathType(root); + if (type === "missing" && create) { + await mkdir2(root, { recursive: true }); + type = await pathType(root); + } + if (type === "missing" && allowMissing) { + return path6.resolve(root); + } + if (type !== "directory") { + throw new StashError( + "unsafe-host-root", + `Host root must be a real directory, not a link or special path: "${root}".`, + 3 + ); + } + return realpath4(root); + } + async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { + await this.#assertSourceBoundary(source); + const snapshot = await snapshotTree(source); + if (expectedTreeHash && snapshot.treeHash !== expectedTreeHash) { + throw new StashError( + "source-changed", + `Skill changed before its managed copy could be committed: "${source}".`, + 4 + ); + } + const metadata = parseSkillMetadata(snapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const existingType = await pathType(managedPath); + if (existingType !== "missing") { + if (existingType !== "directory") { + throw new StashError( + "managed-conflict", + `Managed target is not a directory: "${managedPath}".`, + 3 + ); + } + const existingRecord = await this.#readRecord(metadata.name); + const existingSnapshot = await snapshotTree(managedPath); + if (existingRecord && existingRecord.treeHash === snapshot.treeHash && existingSnapshot.treeHash === snapshot.treeHash) { + return { + record: existingRecord, + managedPath, + created: false + }; + } + throw new StashError( + "managed-conflict", + `Managed skill "${metadata.name}" already exists with different content or metadata.`, + 3 + ); + } + if (await this.#readRecord(metadata.name)) { + throw new StashError( + "managed-conflict", + `Lifecycle metadata exists without its managed skill: "${metadata.name}".`, + 3 + ); + } + const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + let committed = false; + try { + await copySnapshot(snapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== snapshot.treeHash) { + throw new StashError( + "copy-verification-failed", + `Staged tree hash differs for "${metadata.name}".`, + 4 + ); + } + const timestamp = new Date(this.#now()).toISOString(); + const record = { + schemaVersion: STORE_SCHEMA_VERSION, + skillId: randomUUID(), + name: metadata.name, + treeHash: snapshot.treeHash, + source: { + kind, + location: snapshot.root, + importedAt: timestamp, + ...sourceUrl ? { url: sourceUrl } : {}, + ...revision ? { revision } : {} + }, + compatibility: metadata.compatibility, + deployments: [], + lastValidatedAt: timestamp + }; + await rename2(stage, managedPath); + committed = true; + try { + await this.#writeRecord(record); + } catch (error) { + await rm(managedPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { record, managedPath, created: true }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + } + async install(request) { + return this.#withLock(async () => { + const stored = await this.#storeSource( + path6.resolve(request.source), + "local-import", + request.sourceUrl, + request.revision + ); + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash + }; + }); + } + async archive(request) { + return this.#withLock(async () => { + const resolvedTarget = resolveHostRoot(request.target); + resolvedTarget.root = await this.#canonicalHostRoot( + resolvedTarget.root, + false + ); + const looksLikeName = NAME_PATTERN2.test(request.source); + const source = path6.resolve( + looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + ); + if (!samePath(path6.dirname(source), resolvedTarget.root)) { + throw new StashError( + "unsafe-archive-source", + `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, + 3 + ); + } + if (await isPluginContained(source)) { + throw new StashError( + "plugin-lifecycle-delegated", + "Plugin-contained skills must be managed by the host plugin manager.", + 3 + ); + } + await this.#assertSourceBoundary(source); + const sourceSnapshot = await snapshotTree(source); + const metadata = parseSkillMetadata(sourceSnapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; + const tombstoneParent = path6.dirname(resolvedTarget.root); + await mkdir2(tombstoneParent, { recursive: true }); + const tombstone = path6.join( + tombstoneParent, + `.stash-archive-${metadata.name}-${randomUUID()}` + ); + const journal = { + schemaVersion: 1, + operationId: randomUUID(), + stage: "started", + source, + tombstone, + name: metadata.name, + treeHash: sourceSnapshot.treeHash, + managedPath, + managedExistedBefore, + createdAt: new Date(this.#now()).toISOString() + }; + await this.#writeJournal(journal); + try { + const stored = await this.#storeSource( + source, + "standalone-archive", + request.sourceUrl, + request.revision, + journal.treeHash + ); + if (stored.record.name !== journal.name || stored.record.treeHash !== journal.treeHash || !samePath(stored.managedPath, journal.managedPath)) { + throw new StashError( + "source-changed", + `Archive source changed while the managed copy was being committed: "${source}".`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "managed-committed"); + await rename2(source, tombstone); + await this.#advanceArchiveJournal(journal, "source-tombstoned"); + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== stored.record.treeHash) { + throw new StashError( + "source-changed", + `Archived source changed before removal; recovery will preserve or restore it safely.`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "archive-committed"); + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `The skill is outside host discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + if (!warning) { + try { + await unlink2(this.#journalPath(journal.operationId)); + } catch (error) { + warning = `Archive committed, but its recovery journal remains for later cleanup: ${String(error)}`; + } + } + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash, + ...warning ? { warning } : {} + }; + } catch (error) { + await this.#recoverArchiveJournal( + journal, + this.#journalPath(journal.operationId) + ); + throw error; + } + }); + } + async activate(request) { + return this.#withLock(async () => { + if (!NAME_PATTERN2.test(request.name)) { + throw new StashError("invalid-argument", "Invalid skill name.", 2); + } + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${request.name}" no longer matches its recorded hash.`, + 3 + ); + } + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const existing = await pathType(deploymentPath); + const tracked = record.deployments.find( + (deployment) => samePath(deployment.path, deploymentPath) && deployment.targetId === targetId + ); + if (existing !== "missing") { + if (existing === "directory" && tracked) { + const existingSnapshot = await snapshotTree(deploymentPath); + if (existingSnapshot.treeHash === record.treeHash) { + return { + status: "already-deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment: tracked, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } + } + throw new StashError( + "deployment-conflict", + `Target already exists and will not be overwritten: "${deploymentPath}".`, + 3 + ); + } + const stage = path6.join( + path6.dirname(target.root), + `.stash-deploy-${record.name}-${randomUUID()}` + ); + let committed = false; + try { + await copySnapshot(managedSnapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "copy-verification-failed", + `Deployment verification failed for "${record.name}".`, + 4 + ); + } + await rename2(stage, deploymentPath); + committed = true; + const deployment = { + deploymentId: randomUUID(), + skillId: record.skillId, + targetId, + host: target.host, + scope: target.scope, + root: target.root, + path: deploymentPath, + method: "copy", + ownership: "stash", + treeHash: record.treeHash, + deployedAt: new Date(this.#now()).toISOString() + }; + record.deployments = [ + ...record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ), + deployment + ]; + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + await rm(deploymentPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { + status: "deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + }); + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, false, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 + ); + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path6.join( + path6.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); + throw new StashError( + "deployment-drift", + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, + 3 + ); + } + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); + throw error; + } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: target.host !== "claude-code", + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + }); + } + async status(request = {}) { + const recordsRoot = path6.join(this.#metadataRoot(), "records"); + let names; + if (request.name) { + names = [request.name]; + } else { + try { + names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + names = []; + } else { + throw error; + } + } + } + const skills = []; + for (const name of names) { + const record = await this.#readRecord(name); + if (!record) { + continue; + } + const managedPath = path6.join(this.#managedRoot, name); + const managedType = await pathType(managedPath); + let storeState = managedType === "missing" ? "missing" : "stored"; + let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; + let actualTreeHash; + if (managedType === "directory") { + try { + actualTreeHash = (await snapshotTree(managedPath)).treeHash; + storeIntegrity = actualTreeHash === record.treeHash ? "verified" : "drifted"; + } catch { + storeIntegrity = "drifted"; + } + } + const deployments = []; + for (const deployment of record.deployments) { + const type = await pathType(deployment.path); + if (type === "missing") { + deployments.push({ + ...deployment, + state: "missing", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "absent", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + if (type !== "directory") { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "drifted", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + try { + const deployedHash = (await snapshotTree(deployment.path)).treeHash; + deployments.push({ + ...deployment, + state: deployedHash === deployment.treeHash ? "deployed" : "drifted", + integrity: deployedHash === deployment.treeHash ? "verified" : "drifted", + actualTreeHash: deployedHash, + hostObservation: { + override: "unknown", + discovery: "present", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } catch { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } + } + skills.push({ + skillId: record.skillId, + name, + managedPath, + store: { + state: storeState, + integrity: storeIntegrity, + expectedTreeHash: record.treeHash, + ...actualTreeHash ? { actualTreeHash } : {} + }, + source: record.source, + deployments + }); + } + return { + status: skills.length > 0 ? "ok" : "not-found", + managedRoot: this.#managedRoot, + skills + }; + } +}; +async function createStashLifecycle(options = {}) { + const loaded = await loadConfiguration(options); + const managedRoot = path6.resolve( + loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() + ); + return new StashLifecycleImplementation( + managedRoot, + options.now ?? Date.now + ); +} + +// src/cli.ts +function parseArguments(argv) { + const [command, ...rest] = argv; + const positionals = []; + const flags2 = /* @__PURE__ */ new Map(); + for (let index = 0; index < rest.length; index += 1) { + const current = rest[index]; + if (!current) { + continue; + } + if (!current.startsWith("--")) { + positionals.push(current); + continue; + } + const [rawName, inlineValue] = current.slice(2).split("=", 2); + const name = rawName ?? ""; + let value = inlineValue; + if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { + value = rest[index + 1]; + index += 1; + } + const values = flags2.get(name) ?? []; + values.push(value ?? "true"); + flags2.set(name, values); + } + return { ...command ? { command } : {}, positionals, flags: flags2 }; +} +function flag(args, name) { + return args.flags.get(name)?.at(-1); +} +function flags(args, name) { + const values = args.flags.get(name); + return values && values.length > 0 ? values : void 0; +} +function booleanFlag(args, name) { + return args.flags.has(name) && flag(args, name) !== "false"; +} +function numberFlag(args, name) { + const value = flag(args, name); + if (value === void 0) { + return void 0; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new StashError( + "invalid-argument", + `--${name} must be a number.`, + 2 + ); + } + return parsed; +} +function createOptions(args) { + const root = flag(args, "root"); + const catalogId = flag(args, "root-id") ?? "default"; + const configPath = flag(args, "config"); + const cacheDir = flag(args, "cache-dir"); + const managedRoot = flag(args, "managed-root"); + const catalogs = root ? [ + { + id: catalogId, + root, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ] : void 0; + return { + ...configPath ? { configPath } : {}, + ...cacheDir ? { cacheDir } : {}, + ...managedRoot ? { managedRoot } : {}, + ...catalogs ? { catalogs } : {} + }; +} +function lifecycleTarget(args) { + const host = flag(args, "host"); + const supported = /* @__PURE__ */ new Set([ + "codex", + "claude-code", + "antigravity-ide", + "antigravity-cli" + ]); + if (!host || !supported.has(host)) { + throw new StashError( + "invalid-argument", + "--host must be codex, claude-code, antigravity-ide, or antigravity-cli.", + 2 + ); + } + const scope = flag(args, "scope"); + if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + throw new StashError( + "invalid-argument", + "--scope must be user, workspace, or custom.", + 2 + ); + } + const root = flag(args, "host-root"); + const workspace = flag(args, "workspace"); + return { + host, + ...scope ? { scope } : {}, + ...root ? { root } : {}, + ...workspace ? { workspace } : {} + }; +} +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)} +`); +} +function printResolve(result) { + if (result.status === "no-match") { + process.stdout.write( + result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. +` : "No matching skills.\n" + ); + return; + } + if (result.status !== "ok" && result.status !== "ambiguous-exact") { + process.stdout.write(`Stash: ${result.status} +`); + return; + } + process.stdout.write( + `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" + ); + let currentScope = ""; + for (const match of result.matches) { + const group = match.group ?? "(ungrouped)"; + const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; + const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; + if (scope !== currentScope) { + currentScope = scope; + process.stdout.write(` +${scope} +`); + } + const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; + process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} +`); + process.stdout.write(` ref: ${match.ref} +`); + } + if (result.page.nextCursor) { + process.stdout.write(` +next_cursor: ${result.page.nextCursor} +`); + } +} +function printLifecycle(result) { + process.stdout.write( + `${result.name}: ${result.status} (${result.treeHash}) +skill_id: ${result.skillId} +${result.managedPath} +` + ); + if (result.deployment) { + process.stdout.write(`deployment: ${result.deployment.path} +`); + } + if (result.reloadRequired) { + process.stdout.write("Reload or restart the host before relying on discovery changes.\n"); + } + if (result.warning) { + process.stdout.write(`warning: ${result.warning} +`); + } +} +function usage() { + return `Stash \u2014 on-demand search for local Agent Skills + +Usage: + stash exact [--source ] [--group ] [--catalog ] [--json] + stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] + stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] stash read [--resource ] [--format content|path|json] stash index [--catalog ] [--json] stash doctor [--catalog ] [--json] + stash install [--source-url ] [--revision ] [--json] + stash archive --host [--scope user] [--json] + stash activate --host [--scope user] [--json] + stash deactivate --host [--scope user] [--json] + stash status [name] [--json] Configuration: --config Override STASH_CONFIG/platform config. --root Use one catalog without a config file. --root-id Catalog id used with --root (default: default). --cache-dir Override STASH_CACHE_DIR/platform cache. + --managed-root Override STASH_MANAGED_HOME/platform managed store. + +Lifecycle targeting: + --host codex, claude-code, antigravity-ide, or antigravity-cli. + --scope user or custom (default: user; workspace is rejected). + --host-root Explicit discovery root; implies a custom location. Result pagination never caps the total relevant result set. +Lifecycle commands manage only the Stash-owned store and explicitly selected +standalone skills. They never mutate external catalogs, plugins, or host settings. `; } async function main() { @@ -9403,6 +11123,98 @@ async function main() { } return; } + case "install": + case "import": + case "add": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + `${args.command} requires a local skill directory.`, + 2 + ); + } + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(source)) { + throw new StashError( + "remote-install-unsupported", + "Remote installation is not supported in this release. Stage the skill locally, then install that directory.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.install({ + source, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "archive": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + "archive requires a standalone skill directory or a name with --host.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.archive({ + source, + target, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "activate": + case "deactivate": { + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + `${args.command} requires a managed skill name.`, + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const result = args.command === "activate" ? await lifecycle.activate({ name, target }) : await lifecycle.deactivate({ name, target }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "status": { + const name = args.positionals.join(" ").trim(); + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.status(name ? { name } : {}); + if (json) { + printJson(result); + } else if (result.status === "not-found") { + process.stdout.write(`No managed skills at ${result.managedRoot}. +`); + } else { + for (const skill of result.skills) { + process.stdout.write( + `${skill.name}: store=${skill.store.state}/${skill.store.integrity}, deployments=${skill.deployments.length} +` + ); + for (const deployment of skill.deployments) { + process.stdout.write( + ` - ${deployment.host}/${deployment.scope}: ${deployment.state} (${deployment.path}) +` + ); + } + } + } + return; + } default: throw new StashError( "invalid-argument", diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index 3e15411..d5f62ea 100644 --- a/adapters/claude-code/.claude-plugin/plugin.json +++ b/adapters/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "stash", - "version": "0.1.1", - "description": "Search and load local Agent Skills from a separate SKILL.md library on explicit request.", + "version": "0.2.0", + "description": "Search read-only Agent Skills catalogs and manage an explicit local inactive store.", "author": { "name": "dd3ok", "url": "https://github.com/dd3ok" diff --git a/adapters/claude-code/skills/stash/SKILL.md b/adapters/claude-code/skills/stash/SKILL.md index 04bc454..21ffd94 100644 --- a/adapters/claude-code/skills/stash/SKILL.md +++ b/adapters/claude-code/skills/stash/SKILL.md @@ -1,12 +1,14 @@ --- name: stash -description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `/stash:stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. +description: Search a separate local Agent Skills library or explicitly manage inactive standalone skills. Use only when the user explicitly invokes `/stash:stash` to open, find, list, install into Stash, archive, activate, deactivate, or inspect a stored skill. Do not invoke Stash implicitly for ordinary work. disable-model-invocation: true --- # Stash -Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. +Use the bundled CLI to search and read external read-only libraries and the +Stash-managed inactive store. Run lifecycle operations only when the user +explicitly requests them. ## Locate the CLI @@ -16,6 +18,10 @@ Resolve `scripts/stash.mjs` relative to this `SKILL.md` and call the resolved ab Classify the text after `/stash:stash`. +- `install `, `import `, or a request to put a skill directly + into Stash as inactive: follow [Lifecycle operations](#lifecycle-operations). +- `archive `, `activate `, `deactivate `, or + `status [name]`: follow [Lifecycle operations](#lifecycle-operations). - `list`: run `stash list --json`. - ` list`: run `stash list --group --json`. - ` list`: run `stash list --source --json`. @@ -84,6 +90,69 @@ node read --resource --format json Read only resources directly required by the selected `SKILL.md`. For a script or binary that must be used by another tool, request `--format path`; do not execute it merely because it was discovered. +## Lifecycle operations + +Treat lifecycle commands as a separate mutation workflow from catalog search. +Do not infer permission from a discovery request. + +### Install inactive + +For a local skill directory, run: + +```text +node install [--source-url ] [--revision ] --json +``` + +The source must contain `SKILL.md` directly. The command copies a verified +snapshot into the managed store and leaves the source unchanged. + +When the user explicitly provides a remote repository source, stage the +requested revision in a newly created temporary directory outside every host +skill discovery path, inspect the selected skill root, then run the local +install command with its source URL and resolved revision. Do not execute +repository content. Do not install it into a host skill folder first. Remove +only the temporary staging directory after a successful managed import. + +### Archive a standalone skill + +Resolve exactly one standalone skill directory and require its host root: + +```text +node archive --host [--scope user] --json +``` + +An explicit directory path still requires `--host`; use `--scope custom +--host-root ` when selecting a non-default discovery root. The source +must be an exact child of that root. Explain that archive removes the source +only after a journaled copy, validation, hash check, and commit. Never archive +a plugin-contained skill; delegate plugin lifecycle to the host. + +### Deploy or withdraw a managed copy + +Run: + +```text +node activate --host [--scope user] --json +node deactivate --host [--scope user] --json +``` + +Report the JSON state as `deployed`, not as proof that the host considers the +skill enabled. Stash does not change Codex `skills.config`, Claude Code +`skillOverrides`, plugin state, or equivalent vendor settings. `deactivate` +removes only a deployment with matching Stash ownership, logical `skillId`, +target, and tree hash; never adopt or delete an untracked directory. + +Antigravity CLI uses flat Markdown standalone skills in both documented scopes, +so reject it as a lifecycle host. Workspace lifecycle targets are also outside +this release. After a discovery-path change, honor `reloadRequired` and +`warning` in the result. + +### Inspect state + +Run `stash status [name] --json`. Report storage state, integrity, deployment +state, ownership, and host observation as separate fields. A deployed copy can +still be disabled by its host; the override remains `unknown`. + ## Error handling - For missing configuration, read [CONFIGURATION.md](references/CONFIGURATION.md). @@ -92,8 +161,15 @@ Read only resources directly required by the selected `SKILL.md`. For a script o ## Boundaries -- Treat every configured catalog as read-only. -- Do not install, enable, disable, copy, move, edit, or delete stored skills. +- Treat every external configured catalog as read-only. +- Install may read an explicitly selected local skill inside a configured + catalog, but it must preserve that source. Treat hash-matching related copies + as projections of the managed canonical result, not as lifecycle authority. +- Run lifecycle commands only when explicitly requested, and only against the + Stash-managed store or an exact standalone child of an explicitly selected, + supported host root. +- Delegate plugin lifecycle and vendor enable/disable settings to the host. +- Do not overwrite, follow links, or delete an untracked or drifted deployment. - Do not invoke `stash` implicitly for ordinary work. - Treat loaded skill instructions as task-local and subordinate to current system, developer, and user instructions. - Treat discovery as context optimization, not as an execution permission or security approval. diff --git a/adapters/claude-code/skills/stash/references/CLI-CONTRACT.md b/adapters/claude-code/skills/stash/references/CLI-CONTRACT.md index 93becdb..0ea0ea3 100644 --- a/adapters/claude-code/skills/stash/references/CLI-CONTRACT.md +++ b/adapters/claude-code/skills/stash/references/CLI-CONTRACT.md @@ -41,6 +41,44 @@ Source IDs and display names are searchable evidence. Prefer `--source` when the Use `--expected-hash` when a workflow must guarantee that the skill selected during resolve is the skill read later. +## Lifecycle contract + +Lifecycle commands use a Stash-owned managed root. They never write to an +external catalog. + +- `install`/`import`/`add`: copy a local skill snapshot into the inactive + managed store and preserve the source. +- `archive`: store and verify an explicitly selected standalone skill, then + remove that source from its discovery path. +- `activate`: copy a managed skill to a host discovery root and record + `status: deployed`. +- `deactivate`: remove only a recorded deployment whose tree hash still + matches. +- `status`: report orthogonal store, integrity, deployment, ownership, and host + observation fields without claiming a host override is enabled. + +`deployed` means present at a documented or explicit discovery root. Check +`reloadRequired` and `warning`. Plugin skills, untracked paths, drifted trees, +symlinks/junctions, overwrites, workspace targets, and all Antigravity CLI +flat-Markdown deployment are rejected. Every managed record has a stable +`skillId`; each deployment has its own ID plus the same `skillId`, Stash +ownership, target ID, and expected tree hash. + +When default resolution includes the managed catalog, `relatedCopies` lists +hash-matching preserved sources and Stash-owned deployments that were folded +into the managed canonical result. Catalog-scoped resolution still returns its +own record, raw refs remain readable, and drifted copies remain separate with a +warning. + +Lifecycle lock metadata is atomically published. A proven-dead owner may be +reclaimed under a single-reclaimer guard; live or malformed ownership fails +closed. If a crashed reclaimer leaves the guard behind, follow +the repository maintenance procedure: stop lifecycle commands, verify both the +recorded PID and all Stash processes are absent, back up `.stash`, move the +guard to an external quarantine, trigger journal preflight with an idempotent +mutation, and verify `status`. Never delete a live/malformed main lock or edit a +journal. + ## Exit codes - `0`: command completed, including a normal `no-match`. diff --git a/adapters/claude-code/skills/stash/references/CONFIGURATION.md b/adapters/claude-code/skills/stash/references/CONFIGURATION.md index 813657e..ddf7af5 100644 --- a/adapters/claude-code/skills/stash/references/CONFIGURATION.md +++ b/adapters/claude-code/skills/stash/references/CONFIGURATION.md @@ -12,6 +12,7 @@ Example: ```yaml version: 1 +managedRoot: "D:/skills/stash-managed" catalogs: - id: personal root: "D:/skills/stash" @@ -37,4 +38,22 @@ Alternatives: Run `stash doctor --json`, then `stash index --json` after configuring a catalog. -The router never edits the configured root. Cache data is stored in the platform cache directory or `STASH_CACHE_DIR`. +No configuration file is required when only the managed inactive store is +used. Its default locations are: + +- Windows: `%LOCALAPPDATA%\stash\managed` +- macOS: `~/Library/Application Support/stash/managed` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/stash/managed` + +Override it with `managedRoot`, `STASH_MANAGED_HOME`, or `--managed-root`. +The managed store is automatically included as catalog id `managed` after it +exists. The router never edits any external configured catalog. Cache data is +stored in the platform cache directory or `STASH_CACHE_DIR`. + +Catalog registration never grants lifecycle write authority. `install` may +read a selected skill inside a configured catalog and preserves its source. +When the managed store is part of the same resolve operation, hash-matching +source and Stash-owned deployment records are folded into the managed result's +`relatedCopies`. Drifted or unrelated records remain separate. A catalog-only +resolve still returns that catalog's own records, and their refs remain +readable. diff --git a/adapters/claude-code/skills/stash/scripts/stash.mjs b/adapters/claude-code/skills/stash/scripts/stash.mjs index 7375d61..c1d2319 100644 --- a/adapters/claude-code/skills/stash/scripts/stash.mjs +++ b/adapters/claude-code/skills/stash/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path5) { - const ctrl = callVisitor(key, node, visitor, path5); + function visit_(key, node, visitor, path7) { + const ctrl = callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visit_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visit_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path5); + const ci = visit_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = visit_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = visit_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path5); + const cv = visit_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path5) { - const ctrl = await callVisitor(key, node, visitor, path5); + async function visitAsync_(key, node, visitor, path7) { + const ctrl = await callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visitAsync_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visitAsync_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path5); + const ci = await visitAsync_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path5); + const cv = await visitAsync_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path5) { + function callVisitor(key, node, visitor, path7) { if (typeof visitor === "function") - return visitor(key, node, path5); + return visitor(key, node, path7); if (identity.isMap(node)) - return visitor.Map?.(key, node, path5); + return visitor.Map?.(key, node, path7); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path5); + return visitor.Seq?.(key, node, path7); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path5); + return visitor.Pair?.(key, node, path7); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path5); + return visitor.Scalar?.(key, node, path7); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path5); + return visitor.Alias?.(key, node, path7); return void 0; } - function replaceNode(key, path5, node) { - const parent = path5[path5.length - 1]; + function replaceNode(key, path7, node) { + const parent = path7[path7.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path5, value) { + function collectionFromPath(schema, path7, value) { let v = value; - for (let i = path5.length - 1; i >= 0; --i) { - const k = path5[i]; + for (let i = path7.length - 1; i >= 0; --i) { + const k = path7[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path5) => path5 == null || typeof path5 === "object" && !!path5[Symbol.iterator]().next().done; + var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path5, value) { - if (isEmptyPath(path5)) + addIn(path7, value) { + if (isEmptyPath(path7)) this.add(value); else { - const [key, ...rest] = path5; + const [key, ...rest] = path7; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - const [key, ...rest] = path5; + deleteIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - const [key, ...rest] = path5; + getIn(path7, keepScalar) { + const [key, ...rest] = path7; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path5) { - const [key, ...rest] = path5; + hasIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - const [key, ...rest] = path5; + setIn(path7, value) { + const [key, ...rest] = path7; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path5, value) { + addIn(path7, value) { if (assertCollection(this.contents)) - this.contents.addIn(path5, value); + this.contents.addIn(path7, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - if (Collection.isEmptyPath(path5)) { + deleteIn(path7) { + if (Collection.isEmptyPath(path7)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path5) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - if (Collection.isEmptyPath(path5)) + getIn(path7, keepScalar) { + if (Collection.isEmptyPath(path7)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path5, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path5) { - if (Collection.isEmptyPath(path5)) + hasIn(path7) { + if (Collection.isEmptyPath(path7)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path5) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - if (Collection.isEmptyPath(path5)) { + setIn(path7, value) { + if (Collection.isEmptyPath(path7)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path5), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path5, value); + this.contents.setIn(path7, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path5) => { + visit.itemAtPath = (cst, path7) => { let item = cst; - for (const [field, index] of path5) { + for (const [field, index] of path7) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path5) => { - const parent = visit.itemAtPath(cst, path5.slice(0, -1)); - const field = path5[path5.length - 1][0]; + visit.parentCollection = (cst, path7) => { + const parent = visit.itemAtPath(cst, path7.slice(0, -1)); + const field = path7[path7.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path5, item, visitor) { - let ctrl = visitor(item, path5); + function _visit(path7, item, visitor) { + let ctrl = visitor(item, path7); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path5.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path5); + ctrl = ctrl(item, path7); } } - return typeof ctrl === "function" ? ctrl(item, path5) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; } exports.visit = visit; } @@ -7263,7 +7263,7 @@ var require_public_api = __commonJS({ } return doc; } - function parse3(src, reviver, options) { + function parse4(src, reviver, options) { let _reviver = void 0; if (typeof reviver === "function") { _reviver = reviver; @@ -7304,7 +7304,7 @@ var require_public_api = __commonJS({ return value.toString(options); return new Document.Document(value, _replacer, options).toString(options); } - exports.parse = parse3; + exports.parse = parse4; exports.parseAllDocuments = parseAllDocuments; exports.parseDocument = parseDocument; exports.stringify = stringify; @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile3, realpath as realpath2, stat as stat2 } from "node:fs/promises"; -import path4 from "node:path"; +import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; +import path5 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7538,6 +7538,33 @@ function platformCachePath() { "stash" ); } +function platformManagedPath() { + if (process.env.STASH_MANAGED_HOME) { + return path.resolve(process.env.STASH_MANAGED_HOME); + } + const currentPlatform = platform(); + if (currentPlatform === "win32") { + return path.join( + process.env.LOCALAPPDATA ?? path.join(homedir(), "AppData", "Local"), + "stash", + "managed" + ); + } + if (currentPlatform === "darwin") { + return path.join( + homedir(), + "Library", + "Application Support", + "stash", + "managed" + ); + } + return path.join( + process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share"), + "stash", + "managed" + ); +} function safeCatalogSegment(value) { return value.replace(/[^a-zA-Z0-9._-]/gu, "_"); } @@ -7668,16 +7695,21 @@ function parseDefaults(value, overrides) { async function loadConfiguration(options) { const cacheDir = path2.resolve(options.cacheDir ?? platformCachePath()); if (options.catalogs) { - const catalogs = options.catalogs.map((catalog) => ({ + let catalogs2 = options.catalogs.map((catalog) => ({ ...catalog, root: path2.resolve(catalog.root) })); - validateUniqueCatalogIds(catalogs); + const managedRoot2 = options.managedRoot ? path2.resolve(options.managedRoot) : void 0; + if (managedRoot2) { + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + } + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + ...managedRoot2 ? { managedRoot: managedRoot2 } : {} }, cacheDir }; @@ -7694,23 +7726,29 @@ async function loadConfiguration(options) { 5 ); } - if (!Array.isArray(parsed.catalogs)) { + if (parsed.catalogs !== void 0 && !Array.isArray(parsed.catalogs)) { throw new StashError( "invalid-config", - `Config "${configPath}" must define a catalogs array.`, + `Config "${configPath}" catalogs must be an array.`, 2 ); } const baseDir = path2.dirname(configPath); - const catalogs = parsed.catalogs.map( + let catalogs2 = (Array.isArray(parsed.catalogs) ? parsed.catalogs : []).map( (catalog, index) => parseCatalog(catalog, baseDir, index) ); - validateUniqueCatalogIds(catalogs); + const configuredManagedRoot = typeof parsed.managedRoot === "string" && parsed.managedRoot.trim() ? path2.resolve(baseDir, parsed.managedRoot) : void 0; + const managedRoot2 = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? configuredManagedRoot ?? platformManagedPath() + ); + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(parsed.defaults, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(parsed.defaults, options.defaults), + managedRoot: managedRoot2 }, cacheDir, configPath @@ -7728,29 +7766,92 @@ async function loadConfiguration(options) { ); } } + const managedRoot = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ); if (process.env.STASH_HOME) { + const catalogs2 = await includeManagedCatalog( + [ + { + id: "default", + root: path2.resolve(process.env.STASH_HOME), + enabled: true, + trust: "unreviewed", + followSymlinks: false + } + ], + managedRoot + ); return { configuration: { version: 1, - catalogs: [ - { - id: "default", - root: path2.resolve(process.env.STASH_HOME), - enabled: true, - trust: "unreviewed", - followSymlinks: false - } - ], - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + managedRoot }, cacheDir }; } - throw new StashError( - "config-not-found", - `Stash config was not found at "${configPath}". Set STASH_CONFIG, STASH_HOME, or pass --config.`, - 2 - ); + if (explicitConfig) { + throw new StashError( + "config-not-found", + `Stash config was not found at "${configPath}".`, + 2 + ); + } + const catalogs = await includeManagedCatalog([], managedRoot); + return { + configuration: { + version: 1, + catalogs, + defaults: parseDefaults(void 0, options.defaults), + managedRoot + }, + cacheDir + }; +} +async function includeManagedCatalog(catalogs, managedRoot) { + if (catalogs.some((catalog) => catalog.id === "managed")) { + throw new StashError( + "invalid-config", + 'Catalog id "managed" is reserved for the Stash-managed store.', + 2 + ); + } + const normalizedManagedRoot = path2.resolve(managedRoot); + if (catalogs.some((catalog) => { + const normalizedCatalogRoot = path2.resolve(catalog.root); + return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; + })) { + return catalogs; + } + try { + const info = await stat(managedRoot); + if (!info.isDirectory()) { + throw new StashError( + "invalid-config", + `Managed root is not a directory: "${managedRoot}".`, + 2 + ); + } + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return catalogs; + } + throw error; + } + return [ + ...catalogs, + { + id: "managed", + root: managedRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ]; } function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); @@ -7775,7 +7876,7 @@ import { readdir, realpath, rename, - stat, + stat as stat2, unlink, writeFile } from "node:fs/promises"; @@ -7878,7 +7979,7 @@ function parseSidecar(source, catalog) { }; } async function readBounded(filePath, maxBytes) { - const info = await stat(filePath); + const info = await stat2(filePath); if (info.size > maxBytes) { throw new Error(`File exceeds ${maxBytes} bytes.`); } @@ -7889,7 +7990,7 @@ async function loadManifest(root, catalog, warnings) { let input = {}; let fingerprintPart = ""; try { - const manifestInfo = await stat(manifestPath); + const manifestInfo = await stat2(manifestPath); input = asObject2((0, import_yaml2.parse)(await readBounded(manifestPath, MAX_SIDECAR_BYTES))); fingerprintPart = [ "stash.catalog.yaml", @@ -7961,7 +8062,7 @@ async function discoverCandidates(catalog) { 4 ); } - const rootInfo = await stat(root); + const rootInfo = await stat2(root); if (!rootInfo.isDirectory()) { throw new StashError( "catalog-unavailable", @@ -7987,13 +8088,13 @@ async function discoverCandidates(catalog) { ); if (skillEntry) { const skillFile = path3.join(directory, skillEntry.name); - const info = await stat(skillFile); + const info = await stat2(skillFile); const sidecarPath = path3.join(directory, "stash.meta.yaml"); let sidecarFile; let sidecarModifiedMs; let sidecarSize; try { - const sidecarInfo = await stat(sidecarPath); + const sidecarInfo = await stat2(sidecarPath); if (sidecarInfo.isFile()) { sidecarFile = sidecarPath; sidecarModifiedMs = sidecarInfo.mtimeMs; @@ -8038,7 +8139,7 @@ async function discoverCandidates(catalog) { }); continue; } - const targetInfo = await stat(target); + const targetInfo = await stat2(target); if (targetInfo.isDirectory()) { await walk(target, depth + 1); } @@ -8234,7 +8335,7 @@ async function acquireLock(lockPath) { throw error; } try { - const lockInfo = await stat(lockPath); + const lockInfo = await stat2(lockPath); if (Date.now() - lockInfo.mtimeMs > 3e4) { await unlink(lockPath); continue; @@ -8254,6 +8355,225 @@ async function acquireLock(lockPath) { } } +// src/internal/managed-projection.ts +import { + lstat, + readFile as readFile3, + readdir as readdir2, + realpath as realpath2, + stat as stat3 +} from "node:fs/promises"; +import { platform as platform2 } from "node:os"; +import path4 from "node:path"; +var MAX_FILES = 1e4; +var MAX_TOTAL_BYTES = 256 * 1024 * 1024; +function pathIdentity(value) { + const normalized = path4.resolve(value).normalize("NFKC"); + return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function validRecord(value) { + if (!value || typeof value !== "object") { + return false; + } + const record = value; + return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); +} +async function treeHash(rootInput) { + try { + const rootInfo = await lstat(rootInput); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + return void 0; + } + const root = await realpath2(rootInput); + const entries = []; + let files = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new Error("linked tree"); + } + if (before.isDirectory()) { + const canonical = await realpath2(childPath); + if (!isPathInside(root, canonical)) { + throw new Error("tree escape"); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new Error("special file"); + } + files += 1; + totalBytes += before.size; + if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new Error("tree too large"); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new Error("tree changed"); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return sha256(fingerprint); + } catch { + return void 0; + } +} +function relatedCopy(target, record) { + if (target.kind === "deployment" && target.deployment) { + return { + kind: "deployment", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId, + targetId: target.deployment.targetId, + host: target.deployment.host, + scope: target.deployment.scope + }; + } + return { + kind: "source", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId + }; +} +async function projectManagedCopies(sourceIndexes, managedRoot) { + if (!managedRoot || !sourceIndexes.some((index) => index.catalogId === "managed")) { + return { indexes: sourceIndexes, fingerprintPart: "" }; + } + const indexes = sourceIndexes.map((index) => ({ + ...index, + records: index.records.map((record) => ({ ...record })), + warnings: [...index.warnings] + })); + const managedIndex = indexes.find((index) => index.catalogId === "managed"); + if (!managedIndex) { + return { indexes, fingerprintPart: "" }; + } + let recordFiles; + const recordsRoot = path4.join(managedRoot, ".stash", "records"); + try { + recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + } catch { + return { indexes, fingerprintPart: "" }; + } + const targets = /* @__PURE__ */ new Map(); + const managedRecords = /* @__PURE__ */ new Map(); + for (const file of recordFiles) { + try { + const parsed = JSON.parse( + await readFile3(path4.join(recordsRoot, file), "utf8") + ); + if (!validRecord(parsed)) { + throw new Error("invalid record"); + } + managedRecords.set(parsed.name, parsed); + targets.set(pathIdentity(parsed.source.location), { + kind: "source", + record: parsed, + expectedTreeHash: parsed.treeHash + }); + for (const deployment of parsed.deployments) { + if (deployment.ownership === "stash" && deployment.skillId === parsed.skillId) { + targets.set(pathIdentity(deployment.path), { + kind: "deployment", + record: parsed, + expectedTreeHash: deployment.treeHash, + deployment + }); + } + } + } catch { + managedIndex.warnings.push({ + code: "invalid-lifecycle-record", + message: `Ignored invalid lifecycle projection record "${file}".`, + path: `.stash/records/${file}` + }); + } + } + const canonicalBySkillId = /* @__PURE__ */ new Map(); + for (const record of managedIndex.records) { + const managedRecord = managedRecords.get(record.name); + if (managedRecord) { + record.managedSkillId = managedRecord.skillId; + record.relatedCopies = []; + canonicalBySkillId.set(managedRecord.skillId, record); + } + } + const events = []; + for (const index of indexes) { + if (index.catalogId === "managed") { + continue; + } + const visible = []; + for (const record of index.records) { + const skillRoot = path4.dirname( + path4.resolve(index.root, record.relativeSkillFile) + ); + const target = targets.get(pathIdentity(skillRoot)); + const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; + if (!target || !canonical) { + visible.push(record); + continue; + } + const actualTreeHash = await treeHash(skillRoot); + if (actualTreeHash !== target.expectedTreeHash) { + visible.push(record); + index.warnings.push({ + code: "managed-copy-drift", + message: `Managed-related ${target.kind} "${record.ref}" drifted and remains a separate result.`, + ref: record.ref, + path: record.relativeSkillFile + }); + events.push(`drift:${record.ref}:${actualTreeHash ?? "unavailable"}`); + continue; + } + const related = relatedCopy(target, record); + canonical.relatedCopies = [...canonical.relatedCopies ?? [], related]; + events.push( + `fold:${record.ref}:${target.record.skillId}:${target.kind}:${actualTreeHash}` + ); + } + index.records = visible; + } + for (const record of managedIndex.records) { + if (record.relatedCopies) { + record.relatedCopies.sort( + (left, right) => `${left.kind}:${left.catalogId}:${left.ref}`.localeCompare( + `${right.kind}:${right.catalogId}:${right.ref}`, + "en" + ) + ); + } + } + return { + indexes, + fingerprintPart: sha256(events.sort().join("\n")) + }; +} + // src/internal/search.ts var ROUTING_PROFILE_VERSION = 4; var FIELD_WEIGHTS = { @@ -8583,7 +8903,9 @@ function toListedSkill(record) { compatibility: record.compatibility, trust: record.trust, ...Object.keys(record.source).length > 0 ? { source: record.source } : {}, - contentHash: record.contentHash + contentHash: record.contentHash, + ...record.managedSkillId ? { managedSkillId: record.managedSkillId } : {}, + ...record.relatedCopies && record.relatedCopies.length > 0 ? { relatedCopies: record.relatedCopies } : {} }; } @@ -8623,7 +8945,7 @@ var StashCatalogImplementation = class { this.#cacheDir = cacheDir; this.#now = now; } - async #loadIndexes(catalogIds) { + async #loadIndexes(catalogIds, applyManagedProjection = true) { const selected = this.#selectRegistrations(catalogIds); const indexes = []; for (const catalog of selected) { @@ -8635,12 +8957,18 @@ var StashCatalogImplementation = class { ); indexes.push(loaded.index); } + const projected = applyManagedProjection ? await projectManagedCopies(indexes, this.#configuration.managedRoot) : { indexes, fingerprintPart: "" }; return { - indexes, + indexes: projected.indexes, registrations: selected, - warnings: indexes.flatMap((index) => index.warnings), + warnings: projected.indexes.flatMap((index) => index.warnings), fingerprint: sha256( - indexes.map((index) => `${index.catalogId}:${index.fingerprint}`).sort().join("\n") + [ + ...projected.indexes.map( + (index) => `${index.catalogId}:${index.fingerprint}` + ), + projected.fingerprintPart + ].sort().join("\n") ) }; } @@ -8900,7 +9228,7 @@ var StashCatalogImplementation = class { resource }; } - const loaded = await this.#loadIndexes(); + const loaded = await this.#loadIndexes(void 0, false); let record; let index; for (const candidateIndex of loaded.indexes) { @@ -8929,10 +9257,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path4.resolve(skillRoot, normalizedResource); + const candidatePath = path5.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8944,7 +9272,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath2(candidatePath); + resolvedPath = await realpath3(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8953,7 +9281,7 @@ var StashCatalogImplementation = class { resource }; } - const info = await stat2(resolvedPath); + const info = await stat4(resolvedPath); if (!info.isFile()) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9005,7 +9333,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile3(resolvedPath); + const buffer = await readFile4(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9120,140 +9448,1532 @@ async function createStashCatalog(options = {}) { ); } -// src/cli.ts -function parseArguments(argv) { - const [command, ...rest] = argv; - const positionals = []; - const flags2 = /* @__PURE__ */ new Map(); - for (let index = 0; index < rest.length; index += 1) { - const current = rest[index]; - if (!current) { - continue; - } - if (!current.startsWith("--")) { - positionals.push(current); - continue; - } - const [rawName, inlineValue] = current.slice(2).split("=", 2); - const name = rawName ?? ""; - let value = inlineValue; - if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { - value = rest[index + 1]; - index += 1; - } - const values = flags2.get(name) ?? []; - values.push(value ?? "true"); - flags2.set(name, values); - } - return { ...command ? { command } : {}, positionals, flags: flags2 }; -} -function flag(args, name) { - return args.flags.get(name)?.at(-1); -} -function flags(args, name) { - const values = args.flags.get(name); - return values && values.length > 0 ? values : void 0; +// src/stash-lifecycle.ts +var import_yaml3 = __toESM(require_dist(), 1); +import { randomUUID } from "node:crypto"; +import { + lstat as lstat2, + mkdir as mkdir2, + readFile as readFile5, + readdir as readdir3, + realpath as realpath4, + rename as rename2, + rm, + stat as stat5, + unlink as unlink2, + writeFile as writeFile2 +} from "node:fs/promises"; +import { homedir as homedir2, platform as platform3 } from "node:os"; +import path6 from "node:path"; +var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +var MAX_FILES2 = 1e4; +var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; +var STORE_SCHEMA_VERSION = 1; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +function asObject3(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } -function booleanFlag(args, name) { - return args.flags.has(name) && flag(args, name) !== "false"; +function compatibilityState2(value) { + return value === "supported" || value === "partial" || value === "unsupported" || value === "unknown" ? value : "unknown"; } -function numberFlag(args, name) { - const value = flag(args, name); - if (value === void 0) { - return void 0; +function parseSkillMetadata(snapshot) { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec( + snapshot.skillSource.replace(/^\uFEFF/u, "") + ); + if (!match?.[1]) { + throw new StashError( + "invalid-skill", + "SKILL.md must contain closed YAML frontmatter.", + 3 + ); } - const parsed = Number(value); - if (!Number.isFinite(parsed)) { + const metadata = asObject3((0, import_yaml3.parse)(match[1])); + if (typeof metadata.name !== "string" || metadata.name.length > 64 || !NAME_PATTERN2.test(metadata.name)) { throw new StashError( - "invalid-argument", - `--${name} must be a number.`, - 2 + "invalid-skill", + "SKILL.md name must use 1-64 lowercase letters, digits, or hyphens.", + 3 ); } - return parsed; -} -function createOptions(args) { - const root = flag(args, "root"); - const catalogId = flag(args, "root-id") ?? "default"; - const configPath = flag(args, "config"); - const cacheDir = flag(args, "cache-dir"); - const catalogs = root ? [ - { - id: catalogId, - root, - enabled: true, - trust: "unreviewed", - followSymlinks: false, - compatibility: ["codex", "claude-code", "antigravity"] + if (typeof metadata.description !== "string" || metadata.description.trim() === "" || metadata.description.length > 1024) { + throw new StashError( + "invalid-skill", + "SKILL.md description must be a non-empty string up to 1024 characters.", + 3 + ); + } + let compatibility = {}; + if (snapshot.sidecarSource) { + const sidecar = asObject3((0, import_yaml3.parse)(snapshot.sidecarSource)); + if (sidecar.schemaVersion !== void 0 && sidecar.schemaVersion !== 1) { + throw new StashError( + "invalid-skill", + "stash.meta.yaml must use schemaVersion: 1.", + 3 + ); } - ] : void 0; + compatibility = asObject3(sidecar.compatibility); + } return { - ...configPath ? { configPath } : {}, - ...cacheDir ? { cacheDir } : {}, - ...catalogs ? { catalogs } : {} + name: metadata.name, + compatibility: { + codex: compatibilityState2(compatibility.codex), + "claude-code": compatibilityState2(compatibility["claude-code"]), + antigravity: compatibilityState2(compatibility.antigravity) + } }; } -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)} -`); +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); } -function printResolve(result) { - if (result.status === "no-match") { - process.stdout.write( - result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. -` : "No matching skills.\n" +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot end in a dot or space: "${segment}".`, + 3 ); - return; - } - if (result.status !== "ok" && result.status !== "ambiguous-exact") { - process.stdout.write(`Stash: ${result.status} -`); - return; - } - process.stdout.write( - `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" - ); - let currentScope = ""; - for (const match of result.matches) { - const group = match.group ?? "(ungrouped)"; - const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; - const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; - if (scope !== currentScope) { - currentScope = scope; - process.stdout.write(` -${scope} -`); - } - const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; - process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} -`); - process.stdout.write(` ref: ${match.ref} -`); } - if (result.page.nextCursor) { - process.stdout.write(` -next_cursor: ${result.page.nextCursor} -`); + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot use the reserved name "${segment}".`, + 3 + ); } } -function usage() { - return `Stash \u2014 on-demand search for local Agent Skills - -Usage: - stash exact [--source ] [--group ] [--catalog ] [--json] - stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] - stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] +async function snapshotTree(sourceRoot) { + const rootInput = path6.resolve(sourceRoot); + const rootInfo = await lstat2(rootInput).catch((error) => { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${rootInput}": ${String(error)}`, + 4 + ); + }); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new StashError( + "unsafe-skill-tree", + `Skill root must be a real directory, not a link: "${rootInput}".`, + 3 + ); + } + const root = await realpath4(rootInput); + const entries = []; + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + let skillSource; + let sidecarSource; + async function walk(directory, relativeDirectory) { + const children = await readdir3(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new StashError( + "unsafe-skill-tree", + `Case-insensitive path collision at "${relativePath}".`, + 3 + ); + } + pathKeys.add(key); + const childPath = path6.join(directory, child.name); + const before = await lstat2(childPath); + if (before.isSymbolicLink()) { + throw new StashError( + "unsafe-skill-tree", + `Symlinks and junctions are not allowed: "${relativePath}".`, + 3 + ); + } + if (before.isDirectory()) { + const canonical = await realpath4(childPath); + if (!isPathInside(root, canonical)) { + throw new StashError( + "unsafe-skill-tree", + `Directory escapes the skill root: "${relativePath}".`, + 3 + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new StashError( + "unsafe-skill-tree", + `Only regular files and directories are allowed: "${relativePath}".`, + 3 + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { + throw new StashError( + "skill-too-large", + `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, + 3 + ); + } + const content = await readFile5(childPath); + const after = await stat5(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new StashError( + "source-changed", + `Skill changed while it was being read: "${relativePath}".`, + 4 + ); + } + const contentHash = sha256(content); + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash + }); + if (relativePath === "SKILL.md") { + skillSource = content.toString("utf8"); + } else if (relativePath === "stash.meta.yaml") { + sidecarSource = content.toString("utf8"); + } + } + } + await walk(root, ""); + if (skillSource === void 0) { + throw new StashError( + "invalid-skill", + `Skill root must contain SKILL.md: "${root}".`, + 3 + ); + } + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + skillSource, + ...sidecarSource !== void 0 ? { sidecarSource } : {} + }; +} +async function copySnapshot(snapshot, destination) { + await mkdir2(destination, { recursive: false }); + for (const entry of snapshot.entries) { + const relative = entry.relativePath.split("/").join(path6.sep); + const target = path6.join(destination, relative); + if (entry.kind === "directory") { + await mkdir2(target, { recursive: false }); + continue; + } + const source = path6.join(snapshot.root, relative); + const content = await readFile5(source); + if (sha256(content) !== entry.contentHash) { + throw new StashError( + "source-changed", + `Skill changed while it was being copied: "${entry.relativePath}".`, + 4 + ); + } + await writeFile2(target, content, { flag: "wx" }); + } +} +async function pathType(target) { + try { + const info = await lstat2(target); + if (info.isSymbolicLink()) { + return "link"; + } + if (info.isDirectory()) { + return "directory"; + } + return "other"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return "missing"; + } + throw error; + } +} +function samePath(left, right) { + const normalizedLeft = path6.resolve(left).normalize("NFKC"); + const normalizedRight = path6.resolve(right).normalize("NFKC"); + return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; +} +function pathIdentity2(value) { + const normalized = path6.resolve(value).normalize("NFKC"); + return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function targetIdentity(target) { + return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; +} +function resolveHostRoot(target) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace") { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + if (target.root) { + return { + host: target.host, + scope: target.scope ?? "custom", + root: path6.resolve(target.root) + }; + } + const scope = target.scope ?? "user"; + if (scope === "custom") { + throw new StashError( + "invalid-argument", + "A custom lifecycle target requires root.", + 2 + ); + } + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".gemini", "config", "skills") + }; + } +} +async function isPluginContained(source) { + let current = path6.dirname(source); + for (let depth = 0; depth < 12; depth += 1) { + const markers = [ + path6.join(current, ".claude-plugin", "plugin.json"), + path6.join(current, ".codex-plugin", "plugin.json"), + path6.join(current, "plugin.json") + ]; + for (const marker of markers) { + if (await pathType(marker) !== "missing") { + return true; + } + } + const parent = path6.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return false; +} +var StashLifecycleImplementation = class { + #managedRoot; + #now; + constructor(managedRoot, now) { + this.#managedRoot = path6.resolve(managedRoot); + this.#now = now; + } + #metadataRoot() { + return path6.join(this.#managedRoot, ".stash"); + } + #recordPath(name) { + return path6.join(this.#metadataRoot(), "records", `${name}.json`); + } + async #ensureLayout() { + await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + try { + await writeFile2( + manifestPath, + "schemaVersion: 1\nid: managed\ndefaults:\n trust: unreviewed\n compatibility:\n - codex\n - claude-code\n - antigravity\nskillDiscovery:\n ignore:\n - .stash\n", + { encoding: "utf8", flag: "wx" } + ); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "EEXIST") { + throw error; + } + } + } + #journalPath(operationId) { + return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + } + async #writeJournal(journal) { + const finalPath = this.#journalPath(journal.operationId); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(journal, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #advanceArchiveJournal(journal, stage) { + const next = { ...journal, stage }; + await this.#writeJournal(next); + journal.stage = stage; + } + async #removeIncompleteManaged(journal) { + if (journal.managedExistedBefore) { + return; + } + const record = await this.#readRecord(journal.name); + if (record && record.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed record changed during archive recovery: "${journal.name}".`, + 4 + ); + } + if (await pathType(journal.managedPath) === "directory") { + const snapshot = await snapshotTree(journal.managedPath); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed tree changed during archive recovery: "${journal.managedPath}".`, + 4 + ); + } + await rm(journal.managedPath, { recursive: true, force: false }); + } + await unlink2(this.#recordPath(journal.name)).catch((error) => { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + }); + } + #validateArchiveJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "managed-committed", + "source-tombstoned", + "archive-committed" + ]); + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + const expectedManagedPath = path6.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + `.stash-archive-${journal.name}-` + ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + } + async #recoverArchiveJournal(journal, journalPath) { + const sourceType = await pathType(journal.source); + const tombstoneType = await pathType(journal.tombstone); + if (journal.stage === "archive-committed") { + if (tombstoneType !== "missing") { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone is not a directory: "${journal.tombstone}".`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rm(journal.tombstone, { recursive: true, force: false }); + } + await unlink2(journalPath); + return; + } + const sourceWasMoved = journal.stage === "source-tombstoned" || sourceType === "missing" && tombstoneType !== "missing"; + if (sourceWasMoved) { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive source and tombstone cannot be reconciled for "${journal.name}".`, + 4 + ); + } + if (sourceType !== "missing") { + throw new StashError( + "archive-restore-conflict", + `Archive recovery preserved "${journal.tombstone}" because "${journal.source}" is occupied.`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rename2(journal.tombstone, journal.source); + } + await this.#removeIncompleteManaged(journal); + await unlink2(journalPath); + } + async #recoverJournals() { + const journalRoot = path6.join(this.#metadataRoot(), "journal"); + const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + for (const file of files) { + const journalPath = path6.join(journalRoot, file); + let journal; + try { + journal = JSON.parse(await readFile5(journalPath, "utf8")); + } catch (error) { + throw new StashError( + "invalid-lifecycle-journal", + `Unable to read lifecycle journal "${journalPath}": ${String(error)}`, + 5 + ); + } + this.#validateArchiveJournal(journal, journalPath); + await this.#recoverArchiveJournal(journal, journalPath); + } + } + async #readLockOwner(lockPath) { + let owner; + try { + owner = JSON.parse( + await readFile5(path6.join(lockPath, "owner.json"), "utf8") + ); + } catch (error) { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is missing or malformed at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + if (owner.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(owner.ownerToken) || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.createdAt !== "string") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is invalid at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + return owner; + } + #ownerIsAlive(owner) { + try { + process.kill(owner.pid, 0); + return true; + } catch { + return false; + } + } + async #publishLock(lockPath, owner) { + const temporaryPath = path6.join( + this.#metadataRoot(), + `.lifecycle-lock-${owner.ownerToken}.tmp` + ); + await mkdir2(temporaryPath, { recursive: false }); + try { + await writeFile2( + path6.join(temporaryPath, "owner.json"), + `${JSON.stringify(owner)} +`, + { encoding: "utf8", flag: "wx" } + ); + try { + await rename2(temporaryPath, lockPath); + return true; + } catch (error) { + if (await pathType(lockPath) === "directory") { + return false; + } + throw error; + } + } finally { + await rm(temporaryPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #reclaimDeadLock(lockPath) { + const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + try { + await mkdir2(guardPath, { recursive: false }); + } catch (error) { + if (await pathType(guardPath) === "directory") { + throw new StashError( + "lifecycle-busy", + `Another process is checking stale lifecycle ownership at "${lockPath}". If no Stash process is running, follow the documented reclaim-guard repair procedure.`, + 4 + ); + } + throw error; + } + try { + if (await pathType(lockPath) === "missing") { + return; + } + if (await pathType(lockPath) !== "directory") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock is not a directory at "${lockPath}".`, + 4 + ); + } + const owner = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(owner)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + const reclaimedPath = path6.join( + this.#metadataRoot(), + `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` + ); + await rename2(lockPath, reclaimedPath); + const reclaimedOwner = await this.#readLockOwner(reclaimedPath); + if (reclaimedOwner.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-corrupt", + "Lifecycle lock ownership changed during stale recovery.", + 4 + ); + } + await rm(reclaimedPath, { recursive: true, force: false }); + } finally { + await rm(guardPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #acquireLock(lockPath) { + const owner = { + schemaVersion: 1, + ownerToken: randomUUID(), + pid: process.pid, + createdAt: new Date(this.#now()).toISOString() + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await this.#publishLock(lockPath, owner)) { + return owner; + } + const existing = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(existing)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + await this.#reclaimDeadLock(lockPath); + } + throw new StashError( + "lifecycle-busy", + `Lifecycle ownership changed while acquiring "${lockPath}".`, + 4 + ); + } + async #releaseLock(lockPath, owner) { + const current = await this.#readLockOwner(lockPath); + if (current.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-lost", + `Lifecycle lock ownership changed at "${lockPath}".`, + 4 + ); + } + await rm(lockPath, { recursive: true, force: false }); + } + async #withLock(operation) { + await this.#ensureLayout(); + const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const owner = await this.#acquireLock(lockPath); + try { + await this.#recoverJournals(); + return await operation(); + } finally { + await this.#releaseLock(lockPath, owner); + } + } + async #readRecord(name) { + const recordPath = this.#recordPath(name); + try { + const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + )) { + throw new Error("invalid lifecycle record shape"); + } + return parsed; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return void 0; + } + throw new StashError( + "invalid-lifecycle-record", + `Unable to read lifecycle record "${recordPath}": ${String(error)}`, + 5 + ); + } + } + async #writeRecord(record) { + const finalPath = this.#recordPath(record.name); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(record, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #assertSourceBoundary(source) { + const canonicalSource = await realpath4(source); + const canonicalManaged = await realpath4(this.#managedRoot); + if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { + throw new StashError( + "unsafe-source", + "A lifecycle source cannot be inside, contain, or equal the managed root.", + 3 + ); + } + } + async #canonicalHostRoot(root, create, allowMissing = false) { + let type = await pathType(root); + if (type === "missing" && create) { + await mkdir2(root, { recursive: true }); + type = await pathType(root); + } + if (type === "missing" && allowMissing) { + return path6.resolve(root); + } + if (type !== "directory") { + throw new StashError( + "unsafe-host-root", + `Host root must be a real directory, not a link or special path: "${root}".`, + 3 + ); + } + return realpath4(root); + } + async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { + await this.#assertSourceBoundary(source); + const snapshot = await snapshotTree(source); + if (expectedTreeHash && snapshot.treeHash !== expectedTreeHash) { + throw new StashError( + "source-changed", + `Skill changed before its managed copy could be committed: "${source}".`, + 4 + ); + } + const metadata = parseSkillMetadata(snapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const existingType = await pathType(managedPath); + if (existingType !== "missing") { + if (existingType !== "directory") { + throw new StashError( + "managed-conflict", + `Managed target is not a directory: "${managedPath}".`, + 3 + ); + } + const existingRecord = await this.#readRecord(metadata.name); + const existingSnapshot = await snapshotTree(managedPath); + if (existingRecord && existingRecord.treeHash === snapshot.treeHash && existingSnapshot.treeHash === snapshot.treeHash) { + return { + record: existingRecord, + managedPath, + created: false + }; + } + throw new StashError( + "managed-conflict", + `Managed skill "${metadata.name}" already exists with different content or metadata.`, + 3 + ); + } + if (await this.#readRecord(metadata.name)) { + throw new StashError( + "managed-conflict", + `Lifecycle metadata exists without its managed skill: "${metadata.name}".`, + 3 + ); + } + const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + let committed = false; + try { + await copySnapshot(snapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== snapshot.treeHash) { + throw new StashError( + "copy-verification-failed", + `Staged tree hash differs for "${metadata.name}".`, + 4 + ); + } + const timestamp = new Date(this.#now()).toISOString(); + const record = { + schemaVersion: STORE_SCHEMA_VERSION, + skillId: randomUUID(), + name: metadata.name, + treeHash: snapshot.treeHash, + source: { + kind, + location: snapshot.root, + importedAt: timestamp, + ...sourceUrl ? { url: sourceUrl } : {}, + ...revision ? { revision } : {} + }, + compatibility: metadata.compatibility, + deployments: [], + lastValidatedAt: timestamp + }; + await rename2(stage, managedPath); + committed = true; + try { + await this.#writeRecord(record); + } catch (error) { + await rm(managedPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { record, managedPath, created: true }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + } + async install(request) { + return this.#withLock(async () => { + const stored = await this.#storeSource( + path6.resolve(request.source), + "local-import", + request.sourceUrl, + request.revision + ); + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash + }; + }); + } + async archive(request) { + return this.#withLock(async () => { + const resolvedTarget = resolveHostRoot(request.target); + resolvedTarget.root = await this.#canonicalHostRoot( + resolvedTarget.root, + false + ); + const looksLikeName = NAME_PATTERN2.test(request.source); + const source = path6.resolve( + looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + ); + if (!samePath(path6.dirname(source), resolvedTarget.root)) { + throw new StashError( + "unsafe-archive-source", + `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, + 3 + ); + } + if (await isPluginContained(source)) { + throw new StashError( + "plugin-lifecycle-delegated", + "Plugin-contained skills must be managed by the host plugin manager.", + 3 + ); + } + await this.#assertSourceBoundary(source); + const sourceSnapshot = await snapshotTree(source); + const metadata = parseSkillMetadata(sourceSnapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; + const tombstoneParent = path6.dirname(resolvedTarget.root); + await mkdir2(tombstoneParent, { recursive: true }); + const tombstone = path6.join( + tombstoneParent, + `.stash-archive-${metadata.name}-${randomUUID()}` + ); + const journal = { + schemaVersion: 1, + operationId: randomUUID(), + stage: "started", + source, + tombstone, + name: metadata.name, + treeHash: sourceSnapshot.treeHash, + managedPath, + managedExistedBefore, + createdAt: new Date(this.#now()).toISOString() + }; + await this.#writeJournal(journal); + try { + const stored = await this.#storeSource( + source, + "standalone-archive", + request.sourceUrl, + request.revision, + journal.treeHash + ); + if (stored.record.name !== journal.name || stored.record.treeHash !== journal.treeHash || !samePath(stored.managedPath, journal.managedPath)) { + throw new StashError( + "source-changed", + `Archive source changed while the managed copy was being committed: "${source}".`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "managed-committed"); + await rename2(source, tombstone); + await this.#advanceArchiveJournal(journal, "source-tombstoned"); + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== stored.record.treeHash) { + throw new StashError( + "source-changed", + `Archived source changed before removal; recovery will preserve or restore it safely.`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "archive-committed"); + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `The skill is outside host discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + if (!warning) { + try { + await unlink2(this.#journalPath(journal.operationId)); + } catch (error) { + warning = `Archive committed, but its recovery journal remains for later cleanup: ${String(error)}`; + } + } + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash, + ...warning ? { warning } : {} + }; + } catch (error) { + await this.#recoverArchiveJournal( + journal, + this.#journalPath(journal.operationId) + ); + throw error; + } + }); + } + async activate(request) { + return this.#withLock(async () => { + if (!NAME_PATTERN2.test(request.name)) { + throw new StashError("invalid-argument", "Invalid skill name.", 2); + } + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${request.name}" no longer matches its recorded hash.`, + 3 + ); + } + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const existing = await pathType(deploymentPath); + const tracked = record.deployments.find( + (deployment) => samePath(deployment.path, deploymentPath) && deployment.targetId === targetId + ); + if (existing !== "missing") { + if (existing === "directory" && tracked) { + const existingSnapshot = await snapshotTree(deploymentPath); + if (existingSnapshot.treeHash === record.treeHash) { + return { + status: "already-deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment: tracked, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } + } + throw new StashError( + "deployment-conflict", + `Target already exists and will not be overwritten: "${deploymentPath}".`, + 3 + ); + } + const stage = path6.join( + path6.dirname(target.root), + `.stash-deploy-${record.name}-${randomUUID()}` + ); + let committed = false; + try { + await copySnapshot(managedSnapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "copy-verification-failed", + `Deployment verification failed for "${record.name}".`, + 4 + ); + } + await rename2(stage, deploymentPath); + committed = true; + const deployment = { + deploymentId: randomUUID(), + skillId: record.skillId, + targetId, + host: target.host, + scope: target.scope, + root: target.root, + path: deploymentPath, + method: "copy", + ownership: "stash", + treeHash: record.treeHash, + deployedAt: new Date(this.#now()).toISOString() + }; + record.deployments = [ + ...record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ), + deployment + ]; + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + await rm(deploymentPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { + status: "deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + }); + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, false, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 + ); + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path6.join( + path6.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); + throw new StashError( + "deployment-drift", + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, + 3 + ); + } + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); + throw error; + } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: target.host !== "claude-code", + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + }); + } + async status(request = {}) { + const recordsRoot = path6.join(this.#metadataRoot(), "records"); + let names; + if (request.name) { + names = [request.name]; + } else { + try { + names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + names = []; + } else { + throw error; + } + } + } + const skills = []; + for (const name of names) { + const record = await this.#readRecord(name); + if (!record) { + continue; + } + const managedPath = path6.join(this.#managedRoot, name); + const managedType = await pathType(managedPath); + let storeState = managedType === "missing" ? "missing" : "stored"; + let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; + let actualTreeHash; + if (managedType === "directory") { + try { + actualTreeHash = (await snapshotTree(managedPath)).treeHash; + storeIntegrity = actualTreeHash === record.treeHash ? "verified" : "drifted"; + } catch { + storeIntegrity = "drifted"; + } + } + const deployments = []; + for (const deployment of record.deployments) { + const type = await pathType(deployment.path); + if (type === "missing") { + deployments.push({ + ...deployment, + state: "missing", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "absent", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + if (type !== "directory") { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "drifted", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + try { + const deployedHash = (await snapshotTree(deployment.path)).treeHash; + deployments.push({ + ...deployment, + state: deployedHash === deployment.treeHash ? "deployed" : "drifted", + integrity: deployedHash === deployment.treeHash ? "verified" : "drifted", + actualTreeHash: deployedHash, + hostObservation: { + override: "unknown", + discovery: "present", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } catch { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } + } + skills.push({ + skillId: record.skillId, + name, + managedPath, + store: { + state: storeState, + integrity: storeIntegrity, + expectedTreeHash: record.treeHash, + ...actualTreeHash ? { actualTreeHash } : {} + }, + source: record.source, + deployments + }); + } + return { + status: skills.length > 0 ? "ok" : "not-found", + managedRoot: this.#managedRoot, + skills + }; + } +}; +async function createStashLifecycle(options = {}) { + const loaded = await loadConfiguration(options); + const managedRoot = path6.resolve( + loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() + ); + return new StashLifecycleImplementation( + managedRoot, + options.now ?? Date.now + ); +} + +// src/cli.ts +function parseArguments(argv) { + const [command, ...rest] = argv; + const positionals = []; + const flags2 = /* @__PURE__ */ new Map(); + for (let index = 0; index < rest.length; index += 1) { + const current = rest[index]; + if (!current) { + continue; + } + if (!current.startsWith("--")) { + positionals.push(current); + continue; + } + const [rawName, inlineValue] = current.slice(2).split("=", 2); + const name = rawName ?? ""; + let value = inlineValue; + if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { + value = rest[index + 1]; + index += 1; + } + const values = flags2.get(name) ?? []; + values.push(value ?? "true"); + flags2.set(name, values); + } + return { ...command ? { command } : {}, positionals, flags: flags2 }; +} +function flag(args, name) { + return args.flags.get(name)?.at(-1); +} +function flags(args, name) { + const values = args.flags.get(name); + return values && values.length > 0 ? values : void 0; +} +function booleanFlag(args, name) { + return args.flags.has(name) && flag(args, name) !== "false"; +} +function numberFlag(args, name) { + const value = flag(args, name); + if (value === void 0) { + return void 0; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new StashError( + "invalid-argument", + `--${name} must be a number.`, + 2 + ); + } + return parsed; +} +function createOptions(args) { + const root = flag(args, "root"); + const catalogId = flag(args, "root-id") ?? "default"; + const configPath = flag(args, "config"); + const cacheDir = flag(args, "cache-dir"); + const managedRoot = flag(args, "managed-root"); + const catalogs = root ? [ + { + id: catalogId, + root, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ] : void 0; + return { + ...configPath ? { configPath } : {}, + ...cacheDir ? { cacheDir } : {}, + ...managedRoot ? { managedRoot } : {}, + ...catalogs ? { catalogs } : {} + }; +} +function lifecycleTarget(args) { + const host = flag(args, "host"); + const supported = /* @__PURE__ */ new Set([ + "codex", + "claude-code", + "antigravity-ide", + "antigravity-cli" + ]); + if (!host || !supported.has(host)) { + throw new StashError( + "invalid-argument", + "--host must be codex, claude-code, antigravity-ide, or antigravity-cli.", + 2 + ); + } + const scope = flag(args, "scope"); + if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + throw new StashError( + "invalid-argument", + "--scope must be user, workspace, or custom.", + 2 + ); + } + const root = flag(args, "host-root"); + const workspace = flag(args, "workspace"); + return { + host, + ...scope ? { scope } : {}, + ...root ? { root } : {}, + ...workspace ? { workspace } : {} + }; +} +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)} +`); +} +function printResolve(result) { + if (result.status === "no-match") { + process.stdout.write( + result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. +` : "No matching skills.\n" + ); + return; + } + if (result.status !== "ok" && result.status !== "ambiguous-exact") { + process.stdout.write(`Stash: ${result.status} +`); + return; + } + process.stdout.write( + `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" + ); + let currentScope = ""; + for (const match of result.matches) { + const group = match.group ?? "(ungrouped)"; + const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; + const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; + if (scope !== currentScope) { + currentScope = scope; + process.stdout.write(` +${scope} +`); + } + const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; + process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} +`); + process.stdout.write(` ref: ${match.ref} +`); + } + if (result.page.nextCursor) { + process.stdout.write(` +next_cursor: ${result.page.nextCursor} +`); + } +} +function printLifecycle(result) { + process.stdout.write( + `${result.name}: ${result.status} (${result.treeHash}) +skill_id: ${result.skillId} +${result.managedPath} +` + ); + if (result.deployment) { + process.stdout.write(`deployment: ${result.deployment.path} +`); + } + if (result.reloadRequired) { + process.stdout.write("Reload or restart the host before relying on discovery changes.\n"); + } + if (result.warning) { + process.stdout.write(`warning: ${result.warning} +`); + } +} +function usage() { + return `Stash \u2014 on-demand search for local Agent Skills + +Usage: + stash exact [--source ] [--group ] [--catalog ] [--json] + stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] + stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] stash read [--resource ] [--format content|path|json] stash index [--catalog ] [--json] stash doctor [--catalog ] [--json] + stash install [--source-url ] [--revision ] [--json] + stash archive --host [--scope user] [--json] + stash activate --host [--scope user] [--json] + stash deactivate --host [--scope user] [--json] + stash status [name] [--json] Configuration: --config Override STASH_CONFIG/platform config. --root Use one catalog without a config file. --root-id Catalog id used with --root (default: default). --cache-dir Override STASH_CACHE_DIR/platform cache. + --managed-root Override STASH_MANAGED_HOME/platform managed store. + +Lifecycle targeting: + --host codex, claude-code, antigravity-ide, or antigravity-cli. + --scope user or custom (default: user; workspace is rejected). + --host-root Explicit discovery root; implies a custom location. Result pagination never caps the total relevant result set. +Lifecycle commands manage only the Stash-owned store and explicitly selected +standalone skills. They never mutate external catalogs, plugins, or host settings. `; } async function main() { @@ -9403,6 +11123,98 @@ async function main() { } return; } + case "install": + case "import": + case "add": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + `${args.command} requires a local skill directory.`, + 2 + ); + } + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(source)) { + throw new StashError( + "remote-install-unsupported", + "Remote installation is not supported in this release. Stage the skill locally, then install that directory.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.install({ + source, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "archive": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + "archive requires a standalone skill directory or a name with --host.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.archive({ + source, + target, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "activate": + case "deactivate": { + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + `${args.command} requires a managed skill name.`, + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const result = args.command === "activate" ? await lifecycle.activate({ name, target }) : await lifecycle.deactivate({ name, target }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "status": { + const name = args.positionals.join(" ").trim(); + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.status(name ? { name } : {}); + if (json) { + printJson(result); + } else if (result.status === "not-found") { + process.stdout.write(`No managed skills at ${result.managedRoot}. +`); + } else { + for (const skill of result.skills) { + process.stdout.write( + `${skill.name}: store=${skill.store.state}/${skill.store.integrity}, deployments=${skill.deployments.length} +` + ); + for (const deployment of skill.deployments) { + process.stdout.write( + ` - ${deployment.host}/${deployment.scope}: ${deployment.state} (${deployment.path}) +` + ); + } + } + } + return; + } default: throw new StashError( "invalid-argument", diff --git a/adapters/codex/.codex-plugin/plugin.json b/adapters/codex/.codex-plugin/plugin.json index b405b43..a71aa68 100644 --- a/adapters/codex/.codex-plugin/plugin.json +++ b/adapters/codex/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "stash", - "version": "0.1.1", - "description": "One explicit doorway to a local Agent Skills library—search by name or task and load only the selected SKILL.md.", + "version": "0.2.0", + "description": "Search read-only Agent Skills catalogs and explicitly manage a local inactive standalone skill store.", "author": { "name": "dd3ok", "url": "https://github.com/dd3ok" @@ -24,18 +24,19 @@ "skills": "./skills/", "interface": { "displayName": "Stash", - "shortDescription": "Open local Agent Skills only when requested.", - "longDescription": "Keep a large local Agent Skills library behind one explicit entry point. Search by exact name or task, then load only the selected SKILL.md instructions and required resources.", + "shortDescription": "Search and manage inactive local Agent Skills.", + "longDescription": "Keep a large local Agent Skills library behind one explicit entry point. Search external read-only catalogs, import standalone skills into an inactive managed store, and deploy only explicitly selected copies.", "developerName": "dd3ok", "category": "Productivity", "capabilities": [ "Local skill discovery", "Progressive disclosure", - "Read-only" + "Managed inactive skills" ], "defaultPrompt": [ "Use $stash design-system to load that exact stored skill.", - "Use $stash to find every stored skill relevant to this task." + "Use $stash to find every stored skill relevant to this task.", + "Use $stash to install this local skill as inactive." ] } } diff --git a/adapters/codex/skills/stash/SKILL.md b/adapters/codex/skills/stash/SKILL.md index dc5a2c8..be8a403 100644 --- a/adapters/codex/skills/stash/SKILL.md +++ b/adapters/codex/skills/stash/SKILL.md @@ -1,11 +1,13 @@ --- name: stash -description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `$stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. +description: Search a separate local Agent Skills library or explicitly manage inactive standalone skills. Use only when the user explicitly invokes `$stash` to open, find, list, install into Stash, archive, activate, deactivate, or inspect a stored skill. Do not invoke Stash implicitly for ordinary work. --- # Stash -Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. +Use the bundled CLI to search and read external read-only libraries and the +Stash-managed inactive store. Run lifecycle operations only when the user +explicitly requests them. ## Locate the CLI @@ -15,6 +17,10 @@ Resolve `scripts/stash.mjs` relative to this `SKILL.md` and call the resolved ab Classify the text after `$stash`. +- `install `, `import `, or a request to put a skill directly + into Stash as inactive: follow [Lifecycle operations](#lifecycle-operations). +- `archive `, `activate `, `deactivate `, or + `status [name]`: follow [Lifecycle operations](#lifecycle-operations). - `list`: run `stash list --json`. - ` list`: run `stash list --group --json`. - ` list`: run `stash list --source --json`. @@ -83,6 +89,69 @@ node read --resource --format json Read only resources directly required by the selected `SKILL.md`. For a script or binary that must be used by another tool, request `--format path`; do not execute it merely because it was discovered. +## Lifecycle operations + +Treat lifecycle commands as a separate mutation workflow from catalog search. +Do not infer permission from a discovery request. + +### Install inactive + +For a local skill directory, run: + +```text +node install [--source-url ] [--revision ] --json +``` + +The source must contain `SKILL.md` directly. The command copies a verified +snapshot into the managed store and leaves the source unchanged. + +When the user explicitly provides a remote repository source, stage the +requested revision in a newly created temporary directory outside every host +skill discovery path, inspect the selected skill root, then run the local +install command with its source URL and resolved revision. Do not execute +repository content. Do not install it into a host skill folder first. Remove +only the temporary staging directory after a successful managed import. + +### Archive a standalone skill + +Resolve exactly one standalone skill directory and require its host root: + +```text +node archive --host [--scope user] --json +``` + +An explicit directory path still requires `--host`; use `--scope custom +--host-root ` when selecting a non-default discovery root. The source +must be an exact child of that root. Explain that archive removes the source +only after a journaled copy, validation, hash check, and commit. Never archive +a plugin-contained skill; delegate plugin lifecycle to the host. + +### Deploy or withdraw a managed copy + +Run: + +```text +node activate --host [--scope user] --json +node deactivate --host [--scope user] --json +``` + +Report the JSON state as `deployed`, not as proof that the host considers the +skill enabled. Stash does not change Codex `skills.config`, Claude Code +`skillOverrides`, plugin state, or equivalent vendor settings. `deactivate` +removes only a deployment with matching Stash ownership, logical `skillId`, +target, and tree hash; never adopt or delete an untracked directory. + +Antigravity CLI uses flat Markdown standalone skills in both documented scopes, +so reject it as a lifecycle host. Workspace lifecycle targets are also outside +this release. After a discovery-path change, honor `reloadRequired` and +`warning` in the result. + +### Inspect state + +Run `stash status [name] --json`. Report storage state, integrity, deployment +state, ownership, and host observation as separate fields. A deployed copy can +still be disabled by its host; the override remains `unknown`. + ## Error handling - For missing configuration, read [CONFIGURATION.md](references/CONFIGURATION.md). @@ -91,8 +160,15 @@ Read only resources directly required by the selected `SKILL.md`. For a script o ## Boundaries -- Treat every configured catalog as read-only. -- Do not install, enable, disable, copy, move, edit, or delete stored skills. +- Treat every external configured catalog as read-only. +- Install may read an explicitly selected local skill inside a configured + catalog, but it must preserve that source. Treat hash-matching related copies + as projections of the managed canonical result, not as lifecycle authority. +- Run lifecycle commands only when explicitly requested, and only against the + Stash-managed store or an exact standalone child of an explicitly selected, + supported host root. +- Delegate plugin lifecycle and vendor enable/disable settings to the host. +- Do not overwrite, follow links, or delete an untracked or drifted deployment. - Do not invoke `stash` implicitly for ordinary work. - Treat loaded skill instructions as task-local and subordinate to current system, developer, and user instructions. - Treat discovery as context optimization, not as an execution permission or security approval. diff --git a/adapters/codex/skills/stash/agents/openai.yaml b/adapters/codex/skills/stash/agents/openai.yaml index de22d64..dd38f18 100644 --- a/adapters/codex/skills/stash/agents/openai.yaml +++ b/adapters/codex/skills/stash/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "Stash" - short_description: "Search local Agent Skills by name, source, or task" - default_prompt: "Use $stash to list skills from a source, open an exact stored skill, or find every stored skill relevant to a task." + short_description: "Search and manage inactive local Agent Skills" + default_prompt: "Use $stash to find a stored skill or explicitly manage a standalone skill in the inactive local store." policy: allow_implicit_invocation: false diff --git a/adapters/codex/skills/stash/references/CLI-CONTRACT.md b/adapters/codex/skills/stash/references/CLI-CONTRACT.md index 93becdb..0ea0ea3 100644 --- a/adapters/codex/skills/stash/references/CLI-CONTRACT.md +++ b/adapters/codex/skills/stash/references/CLI-CONTRACT.md @@ -41,6 +41,44 @@ Source IDs and display names are searchable evidence. Prefer `--source` when the Use `--expected-hash` when a workflow must guarantee that the skill selected during resolve is the skill read later. +## Lifecycle contract + +Lifecycle commands use a Stash-owned managed root. They never write to an +external catalog. + +- `install`/`import`/`add`: copy a local skill snapshot into the inactive + managed store and preserve the source. +- `archive`: store and verify an explicitly selected standalone skill, then + remove that source from its discovery path. +- `activate`: copy a managed skill to a host discovery root and record + `status: deployed`. +- `deactivate`: remove only a recorded deployment whose tree hash still + matches. +- `status`: report orthogonal store, integrity, deployment, ownership, and host + observation fields without claiming a host override is enabled. + +`deployed` means present at a documented or explicit discovery root. Check +`reloadRequired` and `warning`. Plugin skills, untracked paths, drifted trees, +symlinks/junctions, overwrites, workspace targets, and all Antigravity CLI +flat-Markdown deployment are rejected. Every managed record has a stable +`skillId`; each deployment has its own ID plus the same `skillId`, Stash +ownership, target ID, and expected tree hash. + +When default resolution includes the managed catalog, `relatedCopies` lists +hash-matching preserved sources and Stash-owned deployments that were folded +into the managed canonical result. Catalog-scoped resolution still returns its +own record, raw refs remain readable, and drifted copies remain separate with a +warning. + +Lifecycle lock metadata is atomically published. A proven-dead owner may be +reclaimed under a single-reclaimer guard; live or malformed ownership fails +closed. If a crashed reclaimer leaves the guard behind, follow +the repository maintenance procedure: stop lifecycle commands, verify both the +recorded PID and all Stash processes are absent, back up `.stash`, move the +guard to an external quarantine, trigger journal preflight with an idempotent +mutation, and verify `status`. Never delete a live/malformed main lock or edit a +journal. + ## Exit codes - `0`: command completed, including a normal `no-match`. diff --git a/adapters/codex/skills/stash/references/CONFIGURATION.md b/adapters/codex/skills/stash/references/CONFIGURATION.md index 813657e..ddf7af5 100644 --- a/adapters/codex/skills/stash/references/CONFIGURATION.md +++ b/adapters/codex/skills/stash/references/CONFIGURATION.md @@ -12,6 +12,7 @@ Example: ```yaml version: 1 +managedRoot: "D:/skills/stash-managed" catalogs: - id: personal root: "D:/skills/stash" @@ -37,4 +38,22 @@ Alternatives: Run `stash doctor --json`, then `stash index --json` after configuring a catalog. -The router never edits the configured root. Cache data is stored in the platform cache directory or `STASH_CACHE_DIR`. +No configuration file is required when only the managed inactive store is +used. Its default locations are: + +- Windows: `%LOCALAPPDATA%\stash\managed` +- macOS: `~/Library/Application Support/stash/managed` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/stash/managed` + +Override it with `managedRoot`, `STASH_MANAGED_HOME`, or `--managed-root`. +The managed store is automatically included as catalog id `managed` after it +exists. The router never edits any external configured catalog. Cache data is +stored in the platform cache directory or `STASH_CACHE_DIR`. + +Catalog registration never grants lifecycle write authority. `install` may +read a selected skill inside a configured catalog and preserves its source. +When the managed store is part of the same resolve operation, hash-matching +source and Stash-owned deployment records are folded into the managed result's +`relatedCopies`. Drifted or unrelated records remain separate. A catalog-only +resolve still returns that catalog's own records, and their refs remain +readable. diff --git a/adapters/codex/skills/stash/scripts/stash.mjs b/adapters/codex/skills/stash/scripts/stash.mjs index 7375d61..c1d2319 100644 --- a/adapters/codex/skills/stash/scripts/stash.mjs +++ b/adapters/codex/skills/stash/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path5) { - const ctrl = callVisitor(key, node, visitor, path5); + function visit_(key, node, visitor, path7) { + const ctrl = callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visit_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visit_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path5); + const ci = visit_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = visit_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = visit_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path5); + const cv = visit_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path5) { - const ctrl = await callVisitor(key, node, visitor, path5); + async function visitAsync_(key, node, visitor, path7) { + const ctrl = await callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visitAsync_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visitAsync_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path5); + const ci = await visitAsync_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path5); + const cv = await visitAsync_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path5) { + function callVisitor(key, node, visitor, path7) { if (typeof visitor === "function") - return visitor(key, node, path5); + return visitor(key, node, path7); if (identity.isMap(node)) - return visitor.Map?.(key, node, path5); + return visitor.Map?.(key, node, path7); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path5); + return visitor.Seq?.(key, node, path7); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path5); + return visitor.Pair?.(key, node, path7); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path5); + return visitor.Scalar?.(key, node, path7); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path5); + return visitor.Alias?.(key, node, path7); return void 0; } - function replaceNode(key, path5, node) { - const parent = path5[path5.length - 1]; + function replaceNode(key, path7, node) { + const parent = path7[path7.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path5, value) { + function collectionFromPath(schema, path7, value) { let v = value; - for (let i = path5.length - 1; i >= 0; --i) { - const k = path5[i]; + for (let i = path7.length - 1; i >= 0; --i) { + const k = path7[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path5) => path5 == null || typeof path5 === "object" && !!path5[Symbol.iterator]().next().done; + var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path5, value) { - if (isEmptyPath(path5)) + addIn(path7, value) { + if (isEmptyPath(path7)) this.add(value); else { - const [key, ...rest] = path5; + const [key, ...rest] = path7; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - const [key, ...rest] = path5; + deleteIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - const [key, ...rest] = path5; + getIn(path7, keepScalar) { + const [key, ...rest] = path7; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path5) { - const [key, ...rest] = path5; + hasIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - const [key, ...rest] = path5; + setIn(path7, value) { + const [key, ...rest] = path7; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path5, value) { + addIn(path7, value) { if (assertCollection(this.contents)) - this.contents.addIn(path5, value); + this.contents.addIn(path7, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - if (Collection.isEmptyPath(path5)) { + deleteIn(path7) { + if (Collection.isEmptyPath(path7)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path5) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - if (Collection.isEmptyPath(path5)) + getIn(path7, keepScalar) { + if (Collection.isEmptyPath(path7)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path5, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path5) { - if (Collection.isEmptyPath(path5)) + hasIn(path7) { + if (Collection.isEmptyPath(path7)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path5) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - if (Collection.isEmptyPath(path5)) { + setIn(path7, value) { + if (Collection.isEmptyPath(path7)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path5), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path5, value); + this.contents.setIn(path7, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path5) => { + visit.itemAtPath = (cst, path7) => { let item = cst; - for (const [field, index] of path5) { + for (const [field, index] of path7) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path5) => { - const parent = visit.itemAtPath(cst, path5.slice(0, -1)); - const field = path5[path5.length - 1][0]; + visit.parentCollection = (cst, path7) => { + const parent = visit.itemAtPath(cst, path7.slice(0, -1)); + const field = path7[path7.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path5, item, visitor) { - let ctrl = visitor(item, path5); + function _visit(path7, item, visitor) { + let ctrl = visitor(item, path7); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path5.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path5); + ctrl = ctrl(item, path7); } } - return typeof ctrl === "function" ? ctrl(item, path5) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; } exports.visit = visit; } @@ -7263,7 +7263,7 @@ var require_public_api = __commonJS({ } return doc; } - function parse3(src, reviver, options) { + function parse4(src, reviver, options) { let _reviver = void 0; if (typeof reviver === "function") { _reviver = reviver; @@ -7304,7 +7304,7 @@ var require_public_api = __commonJS({ return value.toString(options); return new Document.Document(value, _replacer, options).toString(options); } - exports.parse = parse3; + exports.parse = parse4; exports.parseAllDocuments = parseAllDocuments; exports.parseDocument = parseDocument; exports.stringify = stringify; @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile3, realpath as realpath2, stat as stat2 } from "node:fs/promises"; -import path4 from "node:path"; +import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; +import path5 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7538,6 +7538,33 @@ function platformCachePath() { "stash" ); } +function platformManagedPath() { + if (process.env.STASH_MANAGED_HOME) { + return path.resolve(process.env.STASH_MANAGED_HOME); + } + const currentPlatform = platform(); + if (currentPlatform === "win32") { + return path.join( + process.env.LOCALAPPDATA ?? path.join(homedir(), "AppData", "Local"), + "stash", + "managed" + ); + } + if (currentPlatform === "darwin") { + return path.join( + homedir(), + "Library", + "Application Support", + "stash", + "managed" + ); + } + return path.join( + process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share"), + "stash", + "managed" + ); +} function safeCatalogSegment(value) { return value.replace(/[^a-zA-Z0-9._-]/gu, "_"); } @@ -7668,16 +7695,21 @@ function parseDefaults(value, overrides) { async function loadConfiguration(options) { const cacheDir = path2.resolve(options.cacheDir ?? platformCachePath()); if (options.catalogs) { - const catalogs = options.catalogs.map((catalog) => ({ + let catalogs2 = options.catalogs.map((catalog) => ({ ...catalog, root: path2.resolve(catalog.root) })); - validateUniqueCatalogIds(catalogs); + const managedRoot2 = options.managedRoot ? path2.resolve(options.managedRoot) : void 0; + if (managedRoot2) { + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + } + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + ...managedRoot2 ? { managedRoot: managedRoot2 } : {} }, cacheDir }; @@ -7694,23 +7726,29 @@ async function loadConfiguration(options) { 5 ); } - if (!Array.isArray(parsed.catalogs)) { + if (parsed.catalogs !== void 0 && !Array.isArray(parsed.catalogs)) { throw new StashError( "invalid-config", - `Config "${configPath}" must define a catalogs array.`, + `Config "${configPath}" catalogs must be an array.`, 2 ); } const baseDir = path2.dirname(configPath); - const catalogs = parsed.catalogs.map( + let catalogs2 = (Array.isArray(parsed.catalogs) ? parsed.catalogs : []).map( (catalog, index) => parseCatalog(catalog, baseDir, index) ); - validateUniqueCatalogIds(catalogs); + const configuredManagedRoot = typeof parsed.managedRoot === "string" && parsed.managedRoot.trim() ? path2.resolve(baseDir, parsed.managedRoot) : void 0; + const managedRoot2 = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? configuredManagedRoot ?? platformManagedPath() + ); + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(parsed.defaults, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(parsed.defaults, options.defaults), + managedRoot: managedRoot2 }, cacheDir, configPath @@ -7728,29 +7766,92 @@ async function loadConfiguration(options) { ); } } + const managedRoot = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ); if (process.env.STASH_HOME) { + const catalogs2 = await includeManagedCatalog( + [ + { + id: "default", + root: path2.resolve(process.env.STASH_HOME), + enabled: true, + trust: "unreviewed", + followSymlinks: false + } + ], + managedRoot + ); return { configuration: { version: 1, - catalogs: [ - { - id: "default", - root: path2.resolve(process.env.STASH_HOME), - enabled: true, - trust: "unreviewed", - followSymlinks: false - } - ], - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + managedRoot }, cacheDir }; } - throw new StashError( - "config-not-found", - `Stash config was not found at "${configPath}". Set STASH_CONFIG, STASH_HOME, or pass --config.`, - 2 - ); + if (explicitConfig) { + throw new StashError( + "config-not-found", + `Stash config was not found at "${configPath}".`, + 2 + ); + } + const catalogs = await includeManagedCatalog([], managedRoot); + return { + configuration: { + version: 1, + catalogs, + defaults: parseDefaults(void 0, options.defaults), + managedRoot + }, + cacheDir + }; +} +async function includeManagedCatalog(catalogs, managedRoot) { + if (catalogs.some((catalog) => catalog.id === "managed")) { + throw new StashError( + "invalid-config", + 'Catalog id "managed" is reserved for the Stash-managed store.', + 2 + ); + } + const normalizedManagedRoot = path2.resolve(managedRoot); + if (catalogs.some((catalog) => { + const normalizedCatalogRoot = path2.resolve(catalog.root); + return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; + })) { + return catalogs; + } + try { + const info = await stat(managedRoot); + if (!info.isDirectory()) { + throw new StashError( + "invalid-config", + `Managed root is not a directory: "${managedRoot}".`, + 2 + ); + } + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return catalogs; + } + throw error; + } + return [ + ...catalogs, + { + id: "managed", + root: managedRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ]; } function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); @@ -7775,7 +7876,7 @@ import { readdir, realpath, rename, - stat, + stat as stat2, unlink, writeFile } from "node:fs/promises"; @@ -7878,7 +7979,7 @@ function parseSidecar(source, catalog) { }; } async function readBounded(filePath, maxBytes) { - const info = await stat(filePath); + const info = await stat2(filePath); if (info.size > maxBytes) { throw new Error(`File exceeds ${maxBytes} bytes.`); } @@ -7889,7 +7990,7 @@ async function loadManifest(root, catalog, warnings) { let input = {}; let fingerprintPart = ""; try { - const manifestInfo = await stat(manifestPath); + const manifestInfo = await stat2(manifestPath); input = asObject2((0, import_yaml2.parse)(await readBounded(manifestPath, MAX_SIDECAR_BYTES))); fingerprintPart = [ "stash.catalog.yaml", @@ -7961,7 +8062,7 @@ async function discoverCandidates(catalog) { 4 ); } - const rootInfo = await stat(root); + const rootInfo = await stat2(root); if (!rootInfo.isDirectory()) { throw new StashError( "catalog-unavailable", @@ -7987,13 +8088,13 @@ async function discoverCandidates(catalog) { ); if (skillEntry) { const skillFile = path3.join(directory, skillEntry.name); - const info = await stat(skillFile); + const info = await stat2(skillFile); const sidecarPath = path3.join(directory, "stash.meta.yaml"); let sidecarFile; let sidecarModifiedMs; let sidecarSize; try { - const sidecarInfo = await stat(sidecarPath); + const sidecarInfo = await stat2(sidecarPath); if (sidecarInfo.isFile()) { sidecarFile = sidecarPath; sidecarModifiedMs = sidecarInfo.mtimeMs; @@ -8038,7 +8139,7 @@ async function discoverCandidates(catalog) { }); continue; } - const targetInfo = await stat(target); + const targetInfo = await stat2(target); if (targetInfo.isDirectory()) { await walk(target, depth + 1); } @@ -8234,7 +8335,7 @@ async function acquireLock(lockPath) { throw error; } try { - const lockInfo = await stat(lockPath); + const lockInfo = await stat2(lockPath); if (Date.now() - lockInfo.mtimeMs > 3e4) { await unlink(lockPath); continue; @@ -8254,6 +8355,225 @@ async function acquireLock(lockPath) { } } +// src/internal/managed-projection.ts +import { + lstat, + readFile as readFile3, + readdir as readdir2, + realpath as realpath2, + stat as stat3 +} from "node:fs/promises"; +import { platform as platform2 } from "node:os"; +import path4 from "node:path"; +var MAX_FILES = 1e4; +var MAX_TOTAL_BYTES = 256 * 1024 * 1024; +function pathIdentity(value) { + const normalized = path4.resolve(value).normalize("NFKC"); + return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function validRecord(value) { + if (!value || typeof value !== "object") { + return false; + } + const record = value; + return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); +} +async function treeHash(rootInput) { + try { + const rootInfo = await lstat(rootInput); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + return void 0; + } + const root = await realpath2(rootInput); + const entries = []; + let files = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new Error("linked tree"); + } + if (before.isDirectory()) { + const canonical = await realpath2(childPath); + if (!isPathInside(root, canonical)) { + throw new Error("tree escape"); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new Error("special file"); + } + files += 1; + totalBytes += before.size; + if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new Error("tree too large"); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new Error("tree changed"); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return sha256(fingerprint); + } catch { + return void 0; + } +} +function relatedCopy(target, record) { + if (target.kind === "deployment" && target.deployment) { + return { + kind: "deployment", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId, + targetId: target.deployment.targetId, + host: target.deployment.host, + scope: target.deployment.scope + }; + } + return { + kind: "source", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId + }; +} +async function projectManagedCopies(sourceIndexes, managedRoot) { + if (!managedRoot || !sourceIndexes.some((index) => index.catalogId === "managed")) { + return { indexes: sourceIndexes, fingerprintPart: "" }; + } + const indexes = sourceIndexes.map((index) => ({ + ...index, + records: index.records.map((record) => ({ ...record })), + warnings: [...index.warnings] + })); + const managedIndex = indexes.find((index) => index.catalogId === "managed"); + if (!managedIndex) { + return { indexes, fingerprintPart: "" }; + } + let recordFiles; + const recordsRoot = path4.join(managedRoot, ".stash", "records"); + try { + recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + } catch { + return { indexes, fingerprintPart: "" }; + } + const targets = /* @__PURE__ */ new Map(); + const managedRecords = /* @__PURE__ */ new Map(); + for (const file of recordFiles) { + try { + const parsed = JSON.parse( + await readFile3(path4.join(recordsRoot, file), "utf8") + ); + if (!validRecord(parsed)) { + throw new Error("invalid record"); + } + managedRecords.set(parsed.name, parsed); + targets.set(pathIdentity(parsed.source.location), { + kind: "source", + record: parsed, + expectedTreeHash: parsed.treeHash + }); + for (const deployment of parsed.deployments) { + if (deployment.ownership === "stash" && deployment.skillId === parsed.skillId) { + targets.set(pathIdentity(deployment.path), { + kind: "deployment", + record: parsed, + expectedTreeHash: deployment.treeHash, + deployment + }); + } + } + } catch { + managedIndex.warnings.push({ + code: "invalid-lifecycle-record", + message: `Ignored invalid lifecycle projection record "${file}".`, + path: `.stash/records/${file}` + }); + } + } + const canonicalBySkillId = /* @__PURE__ */ new Map(); + for (const record of managedIndex.records) { + const managedRecord = managedRecords.get(record.name); + if (managedRecord) { + record.managedSkillId = managedRecord.skillId; + record.relatedCopies = []; + canonicalBySkillId.set(managedRecord.skillId, record); + } + } + const events = []; + for (const index of indexes) { + if (index.catalogId === "managed") { + continue; + } + const visible = []; + for (const record of index.records) { + const skillRoot = path4.dirname( + path4.resolve(index.root, record.relativeSkillFile) + ); + const target = targets.get(pathIdentity(skillRoot)); + const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; + if (!target || !canonical) { + visible.push(record); + continue; + } + const actualTreeHash = await treeHash(skillRoot); + if (actualTreeHash !== target.expectedTreeHash) { + visible.push(record); + index.warnings.push({ + code: "managed-copy-drift", + message: `Managed-related ${target.kind} "${record.ref}" drifted and remains a separate result.`, + ref: record.ref, + path: record.relativeSkillFile + }); + events.push(`drift:${record.ref}:${actualTreeHash ?? "unavailable"}`); + continue; + } + const related = relatedCopy(target, record); + canonical.relatedCopies = [...canonical.relatedCopies ?? [], related]; + events.push( + `fold:${record.ref}:${target.record.skillId}:${target.kind}:${actualTreeHash}` + ); + } + index.records = visible; + } + for (const record of managedIndex.records) { + if (record.relatedCopies) { + record.relatedCopies.sort( + (left, right) => `${left.kind}:${left.catalogId}:${left.ref}`.localeCompare( + `${right.kind}:${right.catalogId}:${right.ref}`, + "en" + ) + ); + } + } + return { + indexes, + fingerprintPart: sha256(events.sort().join("\n")) + }; +} + // src/internal/search.ts var ROUTING_PROFILE_VERSION = 4; var FIELD_WEIGHTS = { @@ -8583,7 +8903,9 @@ function toListedSkill(record) { compatibility: record.compatibility, trust: record.trust, ...Object.keys(record.source).length > 0 ? { source: record.source } : {}, - contentHash: record.contentHash + contentHash: record.contentHash, + ...record.managedSkillId ? { managedSkillId: record.managedSkillId } : {}, + ...record.relatedCopies && record.relatedCopies.length > 0 ? { relatedCopies: record.relatedCopies } : {} }; } @@ -8623,7 +8945,7 @@ var StashCatalogImplementation = class { this.#cacheDir = cacheDir; this.#now = now; } - async #loadIndexes(catalogIds) { + async #loadIndexes(catalogIds, applyManagedProjection = true) { const selected = this.#selectRegistrations(catalogIds); const indexes = []; for (const catalog of selected) { @@ -8635,12 +8957,18 @@ var StashCatalogImplementation = class { ); indexes.push(loaded.index); } + const projected = applyManagedProjection ? await projectManagedCopies(indexes, this.#configuration.managedRoot) : { indexes, fingerprintPart: "" }; return { - indexes, + indexes: projected.indexes, registrations: selected, - warnings: indexes.flatMap((index) => index.warnings), + warnings: projected.indexes.flatMap((index) => index.warnings), fingerprint: sha256( - indexes.map((index) => `${index.catalogId}:${index.fingerprint}`).sort().join("\n") + [ + ...projected.indexes.map( + (index) => `${index.catalogId}:${index.fingerprint}` + ), + projected.fingerprintPart + ].sort().join("\n") ) }; } @@ -8900,7 +9228,7 @@ var StashCatalogImplementation = class { resource }; } - const loaded = await this.#loadIndexes(); + const loaded = await this.#loadIndexes(void 0, false); let record; let index; for (const candidateIndex of loaded.indexes) { @@ -8929,10 +9257,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path4.resolve(skillRoot, normalizedResource); + const candidatePath = path5.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8944,7 +9272,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath2(candidatePath); + resolvedPath = await realpath3(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8953,7 +9281,7 @@ var StashCatalogImplementation = class { resource }; } - const info = await stat2(resolvedPath); + const info = await stat4(resolvedPath); if (!info.isFile()) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9005,7 +9333,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile3(resolvedPath); + const buffer = await readFile4(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9120,140 +9448,1532 @@ async function createStashCatalog(options = {}) { ); } -// src/cli.ts -function parseArguments(argv) { - const [command, ...rest] = argv; - const positionals = []; - const flags2 = /* @__PURE__ */ new Map(); - for (let index = 0; index < rest.length; index += 1) { - const current = rest[index]; - if (!current) { - continue; - } - if (!current.startsWith("--")) { - positionals.push(current); - continue; - } - const [rawName, inlineValue] = current.slice(2).split("=", 2); - const name = rawName ?? ""; - let value = inlineValue; - if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { - value = rest[index + 1]; - index += 1; - } - const values = flags2.get(name) ?? []; - values.push(value ?? "true"); - flags2.set(name, values); - } - return { ...command ? { command } : {}, positionals, flags: flags2 }; -} -function flag(args, name) { - return args.flags.get(name)?.at(-1); -} -function flags(args, name) { - const values = args.flags.get(name); - return values && values.length > 0 ? values : void 0; +// src/stash-lifecycle.ts +var import_yaml3 = __toESM(require_dist(), 1); +import { randomUUID } from "node:crypto"; +import { + lstat as lstat2, + mkdir as mkdir2, + readFile as readFile5, + readdir as readdir3, + realpath as realpath4, + rename as rename2, + rm, + stat as stat5, + unlink as unlink2, + writeFile as writeFile2 +} from "node:fs/promises"; +import { homedir as homedir2, platform as platform3 } from "node:os"; +import path6 from "node:path"; +var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +var MAX_FILES2 = 1e4; +var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; +var STORE_SCHEMA_VERSION = 1; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +function asObject3(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } -function booleanFlag(args, name) { - return args.flags.has(name) && flag(args, name) !== "false"; +function compatibilityState2(value) { + return value === "supported" || value === "partial" || value === "unsupported" || value === "unknown" ? value : "unknown"; } -function numberFlag(args, name) { - const value = flag(args, name); - if (value === void 0) { - return void 0; +function parseSkillMetadata(snapshot) { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec( + snapshot.skillSource.replace(/^\uFEFF/u, "") + ); + if (!match?.[1]) { + throw new StashError( + "invalid-skill", + "SKILL.md must contain closed YAML frontmatter.", + 3 + ); } - const parsed = Number(value); - if (!Number.isFinite(parsed)) { + const metadata = asObject3((0, import_yaml3.parse)(match[1])); + if (typeof metadata.name !== "string" || metadata.name.length > 64 || !NAME_PATTERN2.test(metadata.name)) { throw new StashError( - "invalid-argument", - `--${name} must be a number.`, - 2 + "invalid-skill", + "SKILL.md name must use 1-64 lowercase letters, digits, or hyphens.", + 3 ); } - return parsed; -} -function createOptions(args) { - const root = flag(args, "root"); - const catalogId = flag(args, "root-id") ?? "default"; - const configPath = flag(args, "config"); - const cacheDir = flag(args, "cache-dir"); - const catalogs = root ? [ - { - id: catalogId, - root, - enabled: true, - trust: "unreviewed", - followSymlinks: false, - compatibility: ["codex", "claude-code", "antigravity"] + if (typeof metadata.description !== "string" || metadata.description.trim() === "" || metadata.description.length > 1024) { + throw new StashError( + "invalid-skill", + "SKILL.md description must be a non-empty string up to 1024 characters.", + 3 + ); + } + let compatibility = {}; + if (snapshot.sidecarSource) { + const sidecar = asObject3((0, import_yaml3.parse)(snapshot.sidecarSource)); + if (sidecar.schemaVersion !== void 0 && sidecar.schemaVersion !== 1) { + throw new StashError( + "invalid-skill", + "stash.meta.yaml must use schemaVersion: 1.", + 3 + ); } - ] : void 0; + compatibility = asObject3(sidecar.compatibility); + } return { - ...configPath ? { configPath } : {}, - ...cacheDir ? { cacheDir } : {}, - ...catalogs ? { catalogs } : {} + name: metadata.name, + compatibility: { + codex: compatibilityState2(compatibility.codex), + "claude-code": compatibilityState2(compatibility["claude-code"]), + antigravity: compatibilityState2(compatibility.antigravity) + } }; } -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)} -`); +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); } -function printResolve(result) { - if (result.status === "no-match") { - process.stdout.write( - result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. -` : "No matching skills.\n" +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot end in a dot or space: "${segment}".`, + 3 ); - return; - } - if (result.status !== "ok" && result.status !== "ambiguous-exact") { - process.stdout.write(`Stash: ${result.status} -`); - return; - } - process.stdout.write( - `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" - ); - let currentScope = ""; - for (const match of result.matches) { - const group = match.group ?? "(ungrouped)"; - const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; - const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; - if (scope !== currentScope) { - currentScope = scope; - process.stdout.write(` -${scope} -`); - } - const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; - process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} -`); - process.stdout.write(` ref: ${match.ref} -`); } - if (result.page.nextCursor) { - process.stdout.write(` -next_cursor: ${result.page.nextCursor} -`); + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot use the reserved name "${segment}".`, + 3 + ); } } -function usage() { - return `Stash \u2014 on-demand search for local Agent Skills - -Usage: - stash exact [--source ] [--group ] [--catalog ] [--json] - stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] - stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] +async function snapshotTree(sourceRoot) { + const rootInput = path6.resolve(sourceRoot); + const rootInfo = await lstat2(rootInput).catch((error) => { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${rootInput}": ${String(error)}`, + 4 + ); + }); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new StashError( + "unsafe-skill-tree", + `Skill root must be a real directory, not a link: "${rootInput}".`, + 3 + ); + } + const root = await realpath4(rootInput); + const entries = []; + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + let skillSource; + let sidecarSource; + async function walk(directory, relativeDirectory) { + const children = await readdir3(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new StashError( + "unsafe-skill-tree", + `Case-insensitive path collision at "${relativePath}".`, + 3 + ); + } + pathKeys.add(key); + const childPath = path6.join(directory, child.name); + const before = await lstat2(childPath); + if (before.isSymbolicLink()) { + throw new StashError( + "unsafe-skill-tree", + `Symlinks and junctions are not allowed: "${relativePath}".`, + 3 + ); + } + if (before.isDirectory()) { + const canonical = await realpath4(childPath); + if (!isPathInside(root, canonical)) { + throw new StashError( + "unsafe-skill-tree", + `Directory escapes the skill root: "${relativePath}".`, + 3 + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new StashError( + "unsafe-skill-tree", + `Only regular files and directories are allowed: "${relativePath}".`, + 3 + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { + throw new StashError( + "skill-too-large", + `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, + 3 + ); + } + const content = await readFile5(childPath); + const after = await stat5(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new StashError( + "source-changed", + `Skill changed while it was being read: "${relativePath}".`, + 4 + ); + } + const contentHash = sha256(content); + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash + }); + if (relativePath === "SKILL.md") { + skillSource = content.toString("utf8"); + } else if (relativePath === "stash.meta.yaml") { + sidecarSource = content.toString("utf8"); + } + } + } + await walk(root, ""); + if (skillSource === void 0) { + throw new StashError( + "invalid-skill", + `Skill root must contain SKILL.md: "${root}".`, + 3 + ); + } + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + skillSource, + ...sidecarSource !== void 0 ? { sidecarSource } : {} + }; +} +async function copySnapshot(snapshot, destination) { + await mkdir2(destination, { recursive: false }); + for (const entry of snapshot.entries) { + const relative = entry.relativePath.split("/").join(path6.sep); + const target = path6.join(destination, relative); + if (entry.kind === "directory") { + await mkdir2(target, { recursive: false }); + continue; + } + const source = path6.join(snapshot.root, relative); + const content = await readFile5(source); + if (sha256(content) !== entry.contentHash) { + throw new StashError( + "source-changed", + `Skill changed while it was being copied: "${entry.relativePath}".`, + 4 + ); + } + await writeFile2(target, content, { flag: "wx" }); + } +} +async function pathType(target) { + try { + const info = await lstat2(target); + if (info.isSymbolicLink()) { + return "link"; + } + if (info.isDirectory()) { + return "directory"; + } + return "other"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return "missing"; + } + throw error; + } +} +function samePath(left, right) { + const normalizedLeft = path6.resolve(left).normalize("NFKC"); + const normalizedRight = path6.resolve(right).normalize("NFKC"); + return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; +} +function pathIdentity2(value) { + const normalized = path6.resolve(value).normalize("NFKC"); + return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function targetIdentity(target) { + return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; +} +function resolveHostRoot(target) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace") { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + if (target.root) { + return { + host: target.host, + scope: target.scope ?? "custom", + root: path6.resolve(target.root) + }; + } + const scope = target.scope ?? "user"; + if (scope === "custom") { + throw new StashError( + "invalid-argument", + "A custom lifecycle target requires root.", + 2 + ); + } + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".gemini", "config", "skills") + }; + } +} +async function isPluginContained(source) { + let current = path6.dirname(source); + for (let depth = 0; depth < 12; depth += 1) { + const markers = [ + path6.join(current, ".claude-plugin", "plugin.json"), + path6.join(current, ".codex-plugin", "plugin.json"), + path6.join(current, "plugin.json") + ]; + for (const marker of markers) { + if (await pathType(marker) !== "missing") { + return true; + } + } + const parent = path6.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return false; +} +var StashLifecycleImplementation = class { + #managedRoot; + #now; + constructor(managedRoot, now) { + this.#managedRoot = path6.resolve(managedRoot); + this.#now = now; + } + #metadataRoot() { + return path6.join(this.#managedRoot, ".stash"); + } + #recordPath(name) { + return path6.join(this.#metadataRoot(), "records", `${name}.json`); + } + async #ensureLayout() { + await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + try { + await writeFile2( + manifestPath, + "schemaVersion: 1\nid: managed\ndefaults:\n trust: unreviewed\n compatibility:\n - codex\n - claude-code\n - antigravity\nskillDiscovery:\n ignore:\n - .stash\n", + { encoding: "utf8", flag: "wx" } + ); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "EEXIST") { + throw error; + } + } + } + #journalPath(operationId) { + return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + } + async #writeJournal(journal) { + const finalPath = this.#journalPath(journal.operationId); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(journal, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #advanceArchiveJournal(journal, stage) { + const next = { ...journal, stage }; + await this.#writeJournal(next); + journal.stage = stage; + } + async #removeIncompleteManaged(journal) { + if (journal.managedExistedBefore) { + return; + } + const record = await this.#readRecord(journal.name); + if (record && record.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed record changed during archive recovery: "${journal.name}".`, + 4 + ); + } + if (await pathType(journal.managedPath) === "directory") { + const snapshot = await snapshotTree(journal.managedPath); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed tree changed during archive recovery: "${journal.managedPath}".`, + 4 + ); + } + await rm(journal.managedPath, { recursive: true, force: false }); + } + await unlink2(this.#recordPath(journal.name)).catch((error) => { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + }); + } + #validateArchiveJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "managed-committed", + "source-tombstoned", + "archive-committed" + ]); + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + const expectedManagedPath = path6.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + `.stash-archive-${journal.name}-` + ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + } + async #recoverArchiveJournal(journal, journalPath) { + const sourceType = await pathType(journal.source); + const tombstoneType = await pathType(journal.tombstone); + if (journal.stage === "archive-committed") { + if (tombstoneType !== "missing") { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone is not a directory: "${journal.tombstone}".`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rm(journal.tombstone, { recursive: true, force: false }); + } + await unlink2(journalPath); + return; + } + const sourceWasMoved = journal.stage === "source-tombstoned" || sourceType === "missing" && tombstoneType !== "missing"; + if (sourceWasMoved) { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive source and tombstone cannot be reconciled for "${journal.name}".`, + 4 + ); + } + if (sourceType !== "missing") { + throw new StashError( + "archive-restore-conflict", + `Archive recovery preserved "${journal.tombstone}" because "${journal.source}" is occupied.`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rename2(journal.tombstone, journal.source); + } + await this.#removeIncompleteManaged(journal); + await unlink2(journalPath); + } + async #recoverJournals() { + const journalRoot = path6.join(this.#metadataRoot(), "journal"); + const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + for (const file of files) { + const journalPath = path6.join(journalRoot, file); + let journal; + try { + journal = JSON.parse(await readFile5(journalPath, "utf8")); + } catch (error) { + throw new StashError( + "invalid-lifecycle-journal", + `Unable to read lifecycle journal "${journalPath}": ${String(error)}`, + 5 + ); + } + this.#validateArchiveJournal(journal, journalPath); + await this.#recoverArchiveJournal(journal, journalPath); + } + } + async #readLockOwner(lockPath) { + let owner; + try { + owner = JSON.parse( + await readFile5(path6.join(lockPath, "owner.json"), "utf8") + ); + } catch (error) { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is missing or malformed at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + if (owner.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(owner.ownerToken) || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.createdAt !== "string") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is invalid at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + return owner; + } + #ownerIsAlive(owner) { + try { + process.kill(owner.pid, 0); + return true; + } catch { + return false; + } + } + async #publishLock(lockPath, owner) { + const temporaryPath = path6.join( + this.#metadataRoot(), + `.lifecycle-lock-${owner.ownerToken}.tmp` + ); + await mkdir2(temporaryPath, { recursive: false }); + try { + await writeFile2( + path6.join(temporaryPath, "owner.json"), + `${JSON.stringify(owner)} +`, + { encoding: "utf8", flag: "wx" } + ); + try { + await rename2(temporaryPath, lockPath); + return true; + } catch (error) { + if (await pathType(lockPath) === "directory") { + return false; + } + throw error; + } + } finally { + await rm(temporaryPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #reclaimDeadLock(lockPath) { + const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + try { + await mkdir2(guardPath, { recursive: false }); + } catch (error) { + if (await pathType(guardPath) === "directory") { + throw new StashError( + "lifecycle-busy", + `Another process is checking stale lifecycle ownership at "${lockPath}". If no Stash process is running, follow the documented reclaim-guard repair procedure.`, + 4 + ); + } + throw error; + } + try { + if (await pathType(lockPath) === "missing") { + return; + } + if (await pathType(lockPath) !== "directory") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock is not a directory at "${lockPath}".`, + 4 + ); + } + const owner = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(owner)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + const reclaimedPath = path6.join( + this.#metadataRoot(), + `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` + ); + await rename2(lockPath, reclaimedPath); + const reclaimedOwner = await this.#readLockOwner(reclaimedPath); + if (reclaimedOwner.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-corrupt", + "Lifecycle lock ownership changed during stale recovery.", + 4 + ); + } + await rm(reclaimedPath, { recursive: true, force: false }); + } finally { + await rm(guardPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #acquireLock(lockPath) { + const owner = { + schemaVersion: 1, + ownerToken: randomUUID(), + pid: process.pid, + createdAt: new Date(this.#now()).toISOString() + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await this.#publishLock(lockPath, owner)) { + return owner; + } + const existing = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(existing)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + await this.#reclaimDeadLock(lockPath); + } + throw new StashError( + "lifecycle-busy", + `Lifecycle ownership changed while acquiring "${lockPath}".`, + 4 + ); + } + async #releaseLock(lockPath, owner) { + const current = await this.#readLockOwner(lockPath); + if (current.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-lost", + `Lifecycle lock ownership changed at "${lockPath}".`, + 4 + ); + } + await rm(lockPath, { recursive: true, force: false }); + } + async #withLock(operation) { + await this.#ensureLayout(); + const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const owner = await this.#acquireLock(lockPath); + try { + await this.#recoverJournals(); + return await operation(); + } finally { + await this.#releaseLock(lockPath, owner); + } + } + async #readRecord(name) { + const recordPath = this.#recordPath(name); + try { + const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + )) { + throw new Error("invalid lifecycle record shape"); + } + return parsed; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return void 0; + } + throw new StashError( + "invalid-lifecycle-record", + `Unable to read lifecycle record "${recordPath}": ${String(error)}`, + 5 + ); + } + } + async #writeRecord(record) { + const finalPath = this.#recordPath(record.name); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(record, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #assertSourceBoundary(source) { + const canonicalSource = await realpath4(source); + const canonicalManaged = await realpath4(this.#managedRoot); + if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { + throw new StashError( + "unsafe-source", + "A lifecycle source cannot be inside, contain, or equal the managed root.", + 3 + ); + } + } + async #canonicalHostRoot(root, create, allowMissing = false) { + let type = await pathType(root); + if (type === "missing" && create) { + await mkdir2(root, { recursive: true }); + type = await pathType(root); + } + if (type === "missing" && allowMissing) { + return path6.resolve(root); + } + if (type !== "directory") { + throw new StashError( + "unsafe-host-root", + `Host root must be a real directory, not a link or special path: "${root}".`, + 3 + ); + } + return realpath4(root); + } + async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { + await this.#assertSourceBoundary(source); + const snapshot = await snapshotTree(source); + if (expectedTreeHash && snapshot.treeHash !== expectedTreeHash) { + throw new StashError( + "source-changed", + `Skill changed before its managed copy could be committed: "${source}".`, + 4 + ); + } + const metadata = parseSkillMetadata(snapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const existingType = await pathType(managedPath); + if (existingType !== "missing") { + if (existingType !== "directory") { + throw new StashError( + "managed-conflict", + `Managed target is not a directory: "${managedPath}".`, + 3 + ); + } + const existingRecord = await this.#readRecord(metadata.name); + const existingSnapshot = await snapshotTree(managedPath); + if (existingRecord && existingRecord.treeHash === snapshot.treeHash && existingSnapshot.treeHash === snapshot.treeHash) { + return { + record: existingRecord, + managedPath, + created: false + }; + } + throw new StashError( + "managed-conflict", + `Managed skill "${metadata.name}" already exists with different content or metadata.`, + 3 + ); + } + if (await this.#readRecord(metadata.name)) { + throw new StashError( + "managed-conflict", + `Lifecycle metadata exists without its managed skill: "${metadata.name}".`, + 3 + ); + } + const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + let committed = false; + try { + await copySnapshot(snapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== snapshot.treeHash) { + throw new StashError( + "copy-verification-failed", + `Staged tree hash differs for "${metadata.name}".`, + 4 + ); + } + const timestamp = new Date(this.#now()).toISOString(); + const record = { + schemaVersion: STORE_SCHEMA_VERSION, + skillId: randomUUID(), + name: metadata.name, + treeHash: snapshot.treeHash, + source: { + kind, + location: snapshot.root, + importedAt: timestamp, + ...sourceUrl ? { url: sourceUrl } : {}, + ...revision ? { revision } : {} + }, + compatibility: metadata.compatibility, + deployments: [], + lastValidatedAt: timestamp + }; + await rename2(stage, managedPath); + committed = true; + try { + await this.#writeRecord(record); + } catch (error) { + await rm(managedPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { record, managedPath, created: true }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + } + async install(request) { + return this.#withLock(async () => { + const stored = await this.#storeSource( + path6.resolve(request.source), + "local-import", + request.sourceUrl, + request.revision + ); + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash + }; + }); + } + async archive(request) { + return this.#withLock(async () => { + const resolvedTarget = resolveHostRoot(request.target); + resolvedTarget.root = await this.#canonicalHostRoot( + resolvedTarget.root, + false + ); + const looksLikeName = NAME_PATTERN2.test(request.source); + const source = path6.resolve( + looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + ); + if (!samePath(path6.dirname(source), resolvedTarget.root)) { + throw new StashError( + "unsafe-archive-source", + `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, + 3 + ); + } + if (await isPluginContained(source)) { + throw new StashError( + "plugin-lifecycle-delegated", + "Plugin-contained skills must be managed by the host plugin manager.", + 3 + ); + } + await this.#assertSourceBoundary(source); + const sourceSnapshot = await snapshotTree(source); + const metadata = parseSkillMetadata(sourceSnapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; + const tombstoneParent = path6.dirname(resolvedTarget.root); + await mkdir2(tombstoneParent, { recursive: true }); + const tombstone = path6.join( + tombstoneParent, + `.stash-archive-${metadata.name}-${randomUUID()}` + ); + const journal = { + schemaVersion: 1, + operationId: randomUUID(), + stage: "started", + source, + tombstone, + name: metadata.name, + treeHash: sourceSnapshot.treeHash, + managedPath, + managedExistedBefore, + createdAt: new Date(this.#now()).toISOString() + }; + await this.#writeJournal(journal); + try { + const stored = await this.#storeSource( + source, + "standalone-archive", + request.sourceUrl, + request.revision, + journal.treeHash + ); + if (stored.record.name !== journal.name || stored.record.treeHash !== journal.treeHash || !samePath(stored.managedPath, journal.managedPath)) { + throw new StashError( + "source-changed", + `Archive source changed while the managed copy was being committed: "${source}".`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "managed-committed"); + await rename2(source, tombstone); + await this.#advanceArchiveJournal(journal, "source-tombstoned"); + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== stored.record.treeHash) { + throw new StashError( + "source-changed", + `Archived source changed before removal; recovery will preserve or restore it safely.`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "archive-committed"); + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `The skill is outside host discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + if (!warning) { + try { + await unlink2(this.#journalPath(journal.operationId)); + } catch (error) { + warning = `Archive committed, but its recovery journal remains for later cleanup: ${String(error)}`; + } + } + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash, + ...warning ? { warning } : {} + }; + } catch (error) { + await this.#recoverArchiveJournal( + journal, + this.#journalPath(journal.operationId) + ); + throw error; + } + }); + } + async activate(request) { + return this.#withLock(async () => { + if (!NAME_PATTERN2.test(request.name)) { + throw new StashError("invalid-argument", "Invalid skill name.", 2); + } + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${request.name}" no longer matches its recorded hash.`, + 3 + ); + } + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const existing = await pathType(deploymentPath); + const tracked = record.deployments.find( + (deployment) => samePath(deployment.path, deploymentPath) && deployment.targetId === targetId + ); + if (existing !== "missing") { + if (existing === "directory" && tracked) { + const existingSnapshot = await snapshotTree(deploymentPath); + if (existingSnapshot.treeHash === record.treeHash) { + return { + status: "already-deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment: tracked, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } + } + throw new StashError( + "deployment-conflict", + `Target already exists and will not be overwritten: "${deploymentPath}".`, + 3 + ); + } + const stage = path6.join( + path6.dirname(target.root), + `.stash-deploy-${record.name}-${randomUUID()}` + ); + let committed = false; + try { + await copySnapshot(managedSnapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "copy-verification-failed", + `Deployment verification failed for "${record.name}".`, + 4 + ); + } + await rename2(stage, deploymentPath); + committed = true; + const deployment = { + deploymentId: randomUUID(), + skillId: record.skillId, + targetId, + host: target.host, + scope: target.scope, + root: target.root, + path: deploymentPath, + method: "copy", + ownership: "stash", + treeHash: record.treeHash, + deployedAt: new Date(this.#now()).toISOString() + }; + record.deployments = [ + ...record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ), + deployment + ]; + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + await rm(deploymentPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { + status: "deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + }); + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, false, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 + ); + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path6.join( + path6.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); + throw new StashError( + "deployment-drift", + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, + 3 + ); + } + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); + throw error; + } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: target.host !== "claude-code", + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + }); + } + async status(request = {}) { + const recordsRoot = path6.join(this.#metadataRoot(), "records"); + let names; + if (request.name) { + names = [request.name]; + } else { + try { + names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + names = []; + } else { + throw error; + } + } + } + const skills = []; + for (const name of names) { + const record = await this.#readRecord(name); + if (!record) { + continue; + } + const managedPath = path6.join(this.#managedRoot, name); + const managedType = await pathType(managedPath); + let storeState = managedType === "missing" ? "missing" : "stored"; + let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; + let actualTreeHash; + if (managedType === "directory") { + try { + actualTreeHash = (await snapshotTree(managedPath)).treeHash; + storeIntegrity = actualTreeHash === record.treeHash ? "verified" : "drifted"; + } catch { + storeIntegrity = "drifted"; + } + } + const deployments = []; + for (const deployment of record.deployments) { + const type = await pathType(deployment.path); + if (type === "missing") { + deployments.push({ + ...deployment, + state: "missing", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "absent", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + if (type !== "directory") { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "drifted", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + try { + const deployedHash = (await snapshotTree(deployment.path)).treeHash; + deployments.push({ + ...deployment, + state: deployedHash === deployment.treeHash ? "deployed" : "drifted", + integrity: deployedHash === deployment.treeHash ? "verified" : "drifted", + actualTreeHash: deployedHash, + hostObservation: { + override: "unknown", + discovery: "present", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } catch { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } + } + skills.push({ + skillId: record.skillId, + name, + managedPath, + store: { + state: storeState, + integrity: storeIntegrity, + expectedTreeHash: record.treeHash, + ...actualTreeHash ? { actualTreeHash } : {} + }, + source: record.source, + deployments + }); + } + return { + status: skills.length > 0 ? "ok" : "not-found", + managedRoot: this.#managedRoot, + skills + }; + } +}; +async function createStashLifecycle(options = {}) { + const loaded = await loadConfiguration(options); + const managedRoot = path6.resolve( + loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() + ); + return new StashLifecycleImplementation( + managedRoot, + options.now ?? Date.now + ); +} + +// src/cli.ts +function parseArguments(argv) { + const [command, ...rest] = argv; + const positionals = []; + const flags2 = /* @__PURE__ */ new Map(); + for (let index = 0; index < rest.length; index += 1) { + const current = rest[index]; + if (!current) { + continue; + } + if (!current.startsWith("--")) { + positionals.push(current); + continue; + } + const [rawName, inlineValue] = current.slice(2).split("=", 2); + const name = rawName ?? ""; + let value = inlineValue; + if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { + value = rest[index + 1]; + index += 1; + } + const values = flags2.get(name) ?? []; + values.push(value ?? "true"); + flags2.set(name, values); + } + return { ...command ? { command } : {}, positionals, flags: flags2 }; +} +function flag(args, name) { + return args.flags.get(name)?.at(-1); +} +function flags(args, name) { + const values = args.flags.get(name); + return values && values.length > 0 ? values : void 0; +} +function booleanFlag(args, name) { + return args.flags.has(name) && flag(args, name) !== "false"; +} +function numberFlag(args, name) { + const value = flag(args, name); + if (value === void 0) { + return void 0; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new StashError( + "invalid-argument", + `--${name} must be a number.`, + 2 + ); + } + return parsed; +} +function createOptions(args) { + const root = flag(args, "root"); + const catalogId = flag(args, "root-id") ?? "default"; + const configPath = flag(args, "config"); + const cacheDir = flag(args, "cache-dir"); + const managedRoot = flag(args, "managed-root"); + const catalogs = root ? [ + { + id: catalogId, + root, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ] : void 0; + return { + ...configPath ? { configPath } : {}, + ...cacheDir ? { cacheDir } : {}, + ...managedRoot ? { managedRoot } : {}, + ...catalogs ? { catalogs } : {} + }; +} +function lifecycleTarget(args) { + const host = flag(args, "host"); + const supported = /* @__PURE__ */ new Set([ + "codex", + "claude-code", + "antigravity-ide", + "antigravity-cli" + ]); + if (!host || !supported.has(host)) { + throw new StashError( + "invalid-argument", + "--host must be codex, claude-code, antigravity-ide, or antigravity-cli.", + 2 + ); + } + const scope = flag(args, "scope"); + if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + throw new StashError( + "invalid-argument", + "--scope must be user, workspace, or custom.", + 2 + ); + } + const root = flag(args, "host-root"); + const workspace = flag(args, "workspace"); + return { + host, + ...scope ? { scope } : {}, + ...root ? { root } : {}, + ...workspace ? { workspace } : {} + }; +} +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)} +`); +} +function printResolve(result) { + if (result.status === "no-match") { + process.stdout.write( + result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. +` : "No matching skills.\n" + ); + return; + } + if (result.status !== "ok" && result.status !== "ambiguous-exact") { + process.stdout.write(`Stash: ${result.status} +`); + return; + } + process.stdout.write( + `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" + ); + let currentScope = ""; + for (const match of result.matches) { + const group = match.group ?? "(ungrouped)"; + const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; + const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; + if (scope !== currentScope) { + currentScope = scope; + process.stdout.write(` +${scope} +`); + } + const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; + process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} +`); + process.stdout.write(` ref: ${match.ref} +`); + } + if (result.page.nextCursor) { + process.stdout.write(` +next_cursor: ${result.page.nextCursor} +`); + } +} +function printLifecycle(result) { + process.stdout.write( + `${result.name}: ${result.status} (${result.treeHash}) +skill_id: ${result.skillId} +${result.managedPath} +` + ); + if (result.deployment) { + process.stdout.write(`deployment: ${result.deployment.path} +`); + } + if (result.reloadRequired) { + process.stdout.write("Reload or restart the host before relying on discovery changes.\n"); + } + if (result.warning) { + process.stdout.write(`warning: ${result.warning} +`); + } +} +function usage() { + return `Stash \u2014 on-demand search for local Agent Skills + +Usage: + stash exact [--source ] [--group ] [--catalog ] [--json] + stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] + stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] stash read [--resource ] [--format content|path|json] stash index [--catalog ] [--json] stash doctor [--catalog ] [--json] + stash install [--source-url ] [--revision ] [--json] + stash archive --host [--scope user] [--json] + stash activate --host [--scope user] [--json] + stash deactivate --host [--scope user] [--json] + stash status [name] [--json] Configuration: --config Override STASH_CONFIG/platform config. --root Use one catalog without a config file. --root-id Catalog id used with --root (default: default). --cache-dir Override STASH_CACHE_DIR/platform cache. + --managed-root Override STASH_MANAGED_HOME/platform managed store. + +Lifecycle targeting: + --host codex, claude-code, antigravity-ide, or antigravity-cli. + --scope user or custom (default: user; workspace is rejected). + --host-root Explicit discovery root; implies a custom location. Result pagination never caps the total relevant result set. +Lifecycle commands manage only the Stash-owned store and explicitly selected +standalone skills. They never mutate external catalogs, plugins, or host settings. `; } async function main() { @@ -9403,6 +11123,98 @@ async function main() { } return; } + case "install": + case "import": + case "add": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + `${args.command} requires a local skill directory.`, + 2 + ); + } + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(source)) { + throw new StashError( + "remote-install-unsupported", + "Remote installation is not supported in this release. Stage the skill locally, then install that directory.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.install({ + source, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "archive": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + "archive requires a standalone skill directory or a name with --host.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.archive({ + source, + target, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "activate": + case "deactivate": { + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + `${args.command} requires a managed skill name.`, + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const result = args.command === "activate" ? await lifecycle.activate({ name, target }) : await lifecycle.deactivate({ name, target }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "status": { + const name = args.positionals.join(" ").trim(); + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.status(name ? { name } : {}); + if (json) { + printJson(result); + } else if (result.status === "not-found") { + process.stdout.write(`No managed skills at ${result.managedRoot}. +`); + } else { + for (const skill of result.skills) { + process.stdout.write( + `${skill.name}: store=${skill.store.state}/${skill.store.integrity}, deployments=${skill.deployments.length} +` + ); + for (const deployment of skill.deployments) { + process.stdout.write( + ` - ${deployment.host}/${deployment.scope}: ${deployment.state} (${deployment.path}) +` + ); + } + } + } + return; + } default: throw new StashError( "invalid-argument", diff --git a/docs/alternatives.md b/docs/alternatives.md index 1b7e73a..8d5ee16 100644 --- a/docs/alternatives.md +++ b/docs/alternatives.md @@ -1,28 +1,30 @@ # Related projects and scope -Reviewed on 2026-07-29. This is a scope comparison, not a quality ranking. +Reviewed on 2026-08-01. This is a scope comparison, not a quality ranking. | Project | Primary job | Important difference from Stash | |---|---|---| | [Microsoft Agent Framework skills provider](https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/agents/skills.md) | Discover, load, read, and optionally execute skills inside Microsoft Agent Framework | Framework integration rather than an explicit cross-vendor entry point for a separate local library; script execution is an optional provider capability | | [Block Agent Skills](https://github.com/block/agent-skills) | Curated public skill collection and marketplace | Catalog content and installation, not private local catalog routing | -| [agent-skills-cli](https://github.com/Karanjot786/agent-skills-cli) | Search, install, update, remove, compose, and synchronize skills across many agents | Broader lifecycle manager with remote sources; Stash intentionally has no install or mutation path | +| [agent-skills-cli](https://github.com/Karanjot786/agent-skills-cli) | Search, install, update, remove, compose, and synchronize skills across many agents | Broader remote lifecycle manager; Stash only imports local snapshots and tracks conservative standalone deployments | | [Tech Leads Club Agent Skills](https://github.com/tech-leads-club/agent-skills) | Remote registry plus MCP search/read tools | Closest search-first/read-later shape, but backed by a remote CDN/MCP service rather than read-only local catalogs | | [agentskill.sh / ags](https://github.com/agentskill-sh/ags) | Remote marketplace discovery, security scoring, installation, updates, and feedback | Stronger marketplace lifecycle and reputation features; materially larger trust and network surface | | [Cloudflare Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc) | Proposed `.well-known` discovery and archive distribution contract | Useful future remote-catalog direction, but Stash v0.1 stays local and does not claim this proposal as a standard | ## Decision -Reuse another project when the goal is marketplace discovery, installation, -updates, reputation, or a framework-native provider. Use Stash when the desired +Reuse another project when the goal is marketplace discovery, remote +installation, updates, reputation, or a framework-native provider. Use Stash when the desired boundary is narrower: -- keep an existing local catalog inactive; +- keep an existing local catalog read-only or import a standalone skill into a + managed inactive store; - expose one explicitly named router; - resolve exact names deterministically; - return every materially relevant result without a fixed total cap; - read only the selected skill and requested resources; -- avoid network access, telemetry, execution, and catalog mutation. +- avoid network access, telemetry, execution, external catalog mutation, + plugin lifecycle, and vendor setting mutation. The projects above validate the search-first/progressive-disclosure direction. They do not replace the explicit-only policy adapters or the local read-only diff --git a/docs/architecture.md b/docs/architecture.md index 262c66a..bbf8d55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,11 +3,12 @@ ## Contents - [System shape](#system-shape) -- [StashCatalog Interface](#stashcatalog-interface) +- [Module Interfaces](#module-interfaces) - [Internal implementation](#internal-implementation) - [Adapter seam](#adapter-seam) - [Data flow](#data-flow) - [Cache behavior](#cache-behavior) +- [Lifecycle data flow](#lifecycle-data-flow) - [Rejected extensions](#rejected-extensions) ## System shape @@ -22,13 +23,19 @@ flowchart TD C --> I["Regenerable index cache"] C --> A A --> H["Host agent"] + A --> L["StashLifecycle"] + L --> M["Managed inactive store"] + L --> D["Recorded host deployment"] ``` -The core is one deep Module. Callers learn four operations while the implementation hides catalog traversal, YAML parsing, indexing, relevance, pagination, cache invalidation, hash checks, and safe resource resolution. +The product has two deep Modules with different authority. `StashCatalog` is +read-only across every configured catalog. `StashLifecycle` alone may write to +the Stash-managed root or an explicitly selected standalone deployment target. +It never mutates an external catalog, plugin, or vendor setting. The model keeps three independent dimensions: `catalogId` identifies local storage, `group` supplies functional taxonomy, and `source` records provenance. A source filter accepts an exact ID, display name, or URL. Resolve filters compose across those dimensions before exact lookup, listing, or discovery. -## StashCatalog Interface +## Module Interfaces ```ts interface StashCatalog { @@ -41,11 +48,28 @@ interface StashCatalog { The Interface is the test surface. Search libraries, tokenization, index shape, and filesystem details are implementation details. +```ts +interface StashLifecycle { + install(request: LifecycleInstallRequest): Promise; + archive(request: LifecycleArchiveRequest): Promise; + activate(request: LifecycleActivateRequest): Promise; + deactivate(request: LifecycleDeactivateRequest): Promise; + status(request?: LifecycleStatusRequest): Promise; +} +``` + +Lifecycle state is not one enum. Store presence, tree integrity, deployment +presence, Stash ownership, and host observation are orthogonal fields. A +deployment can still be disabled by a host override, so that observation stays +`unknown`. A path not created and recorded by Stash cannot be removed by +`deactivate`. + ## Internal implementation ```text src/ ├── stash-catalog.ts +├── stash-lifecycle.ts ├── types.ts └── internal/ ├── configuration.ts @@ -61,6 +85,10 @@ Responsibilities: - `search.ts`: normalize text, score lexical evidence, classify relevance, and render compact records. - `util.ts`: hashing, cursor integrity, path containment, tokenization, platform locations. - `stash-catalog.ts`: orchestrate the Interface and normalize errors/results. +- `stash-lifecycle.ts`: validate portable skill trees, serialize mutations, + stage atomic copies, maintain archive recovery journals, record stable skill + and deployment identities, detect drift, and enforce standalone-only + destructive boundaries. ## Adapter seam @@ -112,7 +140,47 @@ The index is not a source of truth. - Explicit `stash index`: rebuild. - `stash doctor`: scan without repairing or mutating the catalog. -Catalog files are never written. +External catalog files are never written. The managed root is a separate +source of truth owned by `StashLifecycle`. + +## Lifecycle data flow + +Install preserves the source: + +```text +explicit local skill → reject links/special files/path collisions + → parse SKILL.md → snapshot tree hash + → hidden same-root staging → copy + re-hash + → atomic rename → provenance record → stored +``` + +Archive adds a destructive second phase only for an explicitly selected +standalone directory: + +```text +journal start → verified managed commit → journal update + → atomic rename outside host discovery → journal update + → re-hash tombstone → archive commit → delete tombstone + journal +``` + +On the next mutation, an incomplete journal either restores the original source +without overwriting an occupied path or finishes committed cleanup. Lock +ownership is atomically published as a complete directory record. A proven-dead +PID is reclaimed under a separate atomic guard; malformed or live ownership +fails closed and is never removed based on age alone. + +Every managed skill has a stable `skillId`; every deployment links to it with a +separate `deploymentId`, target ID, Stash ownership marker, and expected tree +hash. Activation is a tracked copy deployment. Deactivation requires all of +those ownership facts plus the matching tree hash; untracked or drifted content +is preserved. + +Search applies a read-only managed projection. If a configured catalog record's +exact directory and current tree hash match an imported source or Stash-owned +deployment, that record is folded into the managed canonical result's +`relatedCopies`. Drifted or unrelated records remain separate with a warning. +Catalog-scoped lookup bypasses the projection, and raw refs remain readable, so +projection never changes or hides the underlying read-only library. ## Rejected extensions @@ -122,8 +190,10 @@ The first release intentionally excludes: - a second LLM router; - a background daemon; - transcript telemetry; -- skill installation and updates; -- vendor setting mutation; +- remote Git installation and updates; +- symlink deployment and overwrite; +- plugin lifecycle and vendor setting mutation; +- workspace lifecycle targets and flat-file Antigravity CLI skills; - script execution; - a web UI. diff --git a/docs/installation.md b/docs/installation.md index 3c8f5af..141ff5a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,8 +7,24 @@ npm ci npm run build ``` -Configure a catalog as described in the README before invoking Stash. Installing -the router does not move, enable, disable, or modify any catalog skill. +Configure an external catalog as described in the README, or use the managed +store without a config file. Installing the router does not move, enable, +disable, or modify any external catalog skill. + +## Managed store + +The first explicit lifecycle command creates the platform managed root. Override +it with `STASH_MANAGED_HOME`, `--managed-root`, or config `managedRoot`. + +```bash +stash install /path/to/rare-skill +stash status rare-skill +``` + +Remote URLs are not accepted by the CLI. Stage a requested repository revision +outside every host discovery path, review it, and import the local skill root. +Lifecycle deployment is standalone-only: plugins and vendor enable/disable +settings stay under their host's controls. ## OpenAI Codex @@ -94,5 +110,7 @@ before declaring that version supported. ## Uninstall and rollback -Use each host's own plugin or skill lifecycle controls. Removing Stash affects -only the router. Catalogs and their skill contents remain untouched. +Use each host's own plugin controls. Before uninstalling Stash, run +`stash status --json` and deactivate any recorded standalone deployments you +no longer want. Removing the router does not remove external catalogs, the +managed store, or host deployments automatically. diff --git a/docs/maintenance.md b/docs/maintenance.md index 76bfee7..c11a02c 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -51,6 +51,26 @@ Before changing a support claim: Do not infer support from unknown frontmatter being ignored. Codex and Claude have documented manual-only controls; Antigravity currently does not. +## Lifecycle lock repair + +Normal dead-owner recovery is automatic. A crash while holding the short-lived +`.stash/lifecycle.reclaim` guard intentionally fails closed rather than guessing +that no reclaimer is alive. Repair it only after all of these checks: + +1. stop Stash lifecycle commands and confirm no Stash process is running; +2. inspect `.stash/lifecycle.lock/owner.json` and confirm its PID is absent; +3. copy the entire `.stash` metadata directory to a backup outside the managed + root; +4. move `lifecycle.reclaim` to a uniquely named quarantine outside `.stash` + instead of deleting it; +5. run one non-destructive lifecycle mutation such as an idempotent `install`, + allowing the lock preflight to recover any archive journal; +6. run `stash status --json` and retain the quarantine until state is verified. + +Never remove a live owner, treat PID age as proof, edit a journal, or overwrite +an occupied archive source. A malformed main `lifecycle.lock` also requires +manual inspection and remains fail-closed. + ## Routing changes Every scoring change needs: @@ -70,7 +90,19 @@ lexical failure set justifies their operational cost. Preserve these invariants: -- catalogs are read-only; +- external catalogs are read-only; +- lifecycle writes are limited to the managed root and explicit standalone + targets; +- lifecycle never overwrites, follows links, or deletes untracked/drifted paths; +- staged copies and destructive tombstones are hash-verified; +- archives are journaled and recover deterministically without overwriting a + source path that became occupied; +- stable skill/deployment IDs, ownership, targets, and hashes must agree before + withdrawal; +- hash-matching catalog sources and Stash-owned deployments fold into the + managed search projection; drifted or unrelated copies remain visible; +- lock ownership is atomically published, dead owners are reclaimed under a + separate guard, and malformed/live owners fail closed; - reads use refs and relative resources; - `realpath` containment is checked after symlink resolution; - content reads are bounded; diff --git a/docs/vendor-support.md b/docs/vendor-support.md index fe90a27..8736428 100644 --- a/docs/vendor-support.md +++ b/docs/vendor-support.md @@ -1,6 +1,6 @@ # Vendor support -Verified against official documentation on 2026-07-29. Product behavior can change; run the contract tests against the target binary before release. +Verified against official documentation on 2026-08-01. Product behavior can change; run the contract tests against the target binary before release. ## OpenAI Codex @@ -21,8 +21,17 @@ Contract: - explicit invocation: `$stash ...`; - implicit invocation disabled by `policy.allow_implicit_invocation: false`; - `enabled=false` is full disablement and is not used as manual-only; +- standalone user skills live at `~/.agents/skills`, repository skills live in + ancestor `.agents/skills` directories, and symlinked skill folders are + supported; +- `[[skills.config]]` can fully disable an installed skill, but changes require + a restart; - `.codex-plugin/plugin.json` is required for plugin packaging. +Stash lifecycle deploys standalone copies to the documented discovery root but +does not edit `config.toml`. A `deployed` result is therefore not proof that an +existing `enabled=false` override was cleared. + Sources: [Build skills](https://learn.chatgpt.com/docs/build-skills), [Build plugins](https://learn.chatgpt.com/docs/build-plugins), [OpenAI plugin builder documentation](https://developers.openai.com/plugins). ## Anthropic Claude Code @@ -44,6 +53,13 @@ Contract: - generated frontmatter sets `disable-model-invocation: true`; - `skillOverrides` does not control plugin skills; - plugin files must remain inside the plugin root because installed plugins are cached. +- standalone personal skills live at `~/.claude/skills`; project skills live + in `.claude/skills` and support live change detection; +- standalone skills can be hidden with `skillOverrides`, while plugin skills + must be managed through `/plugin`. + +Stash lifecycle never edits plugin cache content or `skillOverrides`. It only +deploys or withdraws a recorded standalone copy. Sources: [Claude Code Skills](https://code.claude.com/docs/en/skills), [Claude Code Plugins](https://code.claude.com/docs/en/plugins), [Plugins reference](https://code.claude.com/docs/en/plugins-reference). @@ -69,7 +85,12 @@ Contract: Because the last point cannot be enforced by metadata, the Adapter documentation requests explicit use but does not claim automatic invocation is technically disabled. -Sources: [Antigravity Agent Skills](https://antigravity.google/docs/skills), [Antigravity Plugins](https://antigravity.google/docs/plugins). +Sources: [Antigravity Agent Skills](https://antigravity.google/docs/skills), [Antigravity IDE Plugins](https://antigravity.google/docs/ide/plugins). + +The current global standalone path is `~/.gemini/config/skills`; workspace +skills use `.agents/skills` (with legacy `.agent/skills` support). Stash can +deploy a directory skill to those roots, but Antigravity documents no +skill-level manual-only override. ## Google Antigravity CLI @@ -82,7 +103,10 @@ adapters/antigravity/cli/ └── scripts/stash.mjs ``` -The current CLI plugin page documents flat Markdown skills that compile to slash commands. A Google Codelab also documents directory-based `SKILL.md` discovery. Their precedence and long-term support are not documented. +The current CLI documentation uses flat Markdown files for both standalone +scopes: `~/.gemini/antigravity-cli/skills/.md` globally and +`.agents/skills/.md` in a workspace. A folder-shaped managed skill cannot +be deployed losslessly to either CLI path. The generated Adapter follows the flat CLI plugin page. Before claiming a supported CLI version: @@ -92,7 +116,10 @@ The generated Adapter follows the flat CLI plugin page. Before claiming a suppor 4. verify natural-language behavior separately; 5. record the tested binary version. -Sources: [Antigravity CLI Plugins & Skills](https://antigravity.google/docs/cli/plugins), [Google Antigravity CLI skills Codelab](https://codelabs.developers.google.com/antigravity/how-to-create-agent-skills-for-antigravity-cli). +Lifecycle commands reject Antigravity CLI in every scope. The plugin adapter is +separate from standalone lifecycle deployment. + +Sources: [Antigravity CLI Plugins & Skills](https://antigravity.google/docs/cli/plugins), [Google Antigravity Skills Codelab](https://codelabs.developers.google.com/getting-started-with-antigravity-skills). ## Support matrix diff --git a/package-lock.json b/package-lock.json index 418a627..c3d4828 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dd3ok/stash", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dd3ok/stash", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "yaml": "^2.8.1" diff --git a/package.json b/package.json index 87768e5..4fda3c5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@dd3ok/stash", - "version": "0.1.1", - "description": "Search a separate local Agent Skills library by name or task and load only the selected SKILL.md", + "version": "0.2.0", + "description": "Search read-only Agent Skills catalogs and manage an explicit local inactive skill store", "type": "module", "bin": { "stash": "./dist/cli.js" diff --git a/scripts/build-adapters.mjs b/scripts/build-adapters.mjs index 39e5129..7793636 100644 --- a/scripts/build-adapters.mjs +++ b/scripts/build-adapters.mjs @@ -71,7 +71,7 @@ await writeJson(path.join(claudeRoot, ".claude-plugin", "plugin.json"), { name: "stash", version: packageVersion, description: - "Search and load local Agent Skills from a separate SKILL.md library on explicit request.", + "Search read-only Agent Skills catalogs and manage an explicit local inactive store.", author: { name: "dd3ok", url: "https://github.com/dd3ok" }, }); @@ -91,7 +91,7 @@ await writeFile( await writeJson(path.join(ideRoot, "plugin.json"), { name: "stash", description: - "Search and load local Agent Skills from a separate SKILL.md library on demand.", + "Search read-only Agent Skills catalogs and manage an explicit local inactive store.", }); const cliRoot = path.join(antigravityRoot, "cli"); @@ -119,5 +119,5 @@ await writeJson(path.join(cliRoot, "plugin.json"), { $schema: "https://antigravity.google/schemas/v1/plugin.json", name: "stash", description: - "Search and load local Agent Skills from a separate SKILL.md library on demand.", + "Search read-only Agent Skills catalogs and manage an explicit local inactive store.", }); diff --git a/skills/stash/SKILL.md b/skills/stash/SKILL.md index dc5a2c8..be8a403 100644 --- a/skills/stash/SKILL.md +++ b/skills/stash/SKILL.md @@ -1,11 +1,13 @@ --- name: stash -description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `$stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. +description: Search a separate local Agent Skills library or explicitly manage inactive standalone skills. Use only when the user explicitly invokes `$stash` to open, find, list, install into Stash, archive, activate, deactivate, or inspect a stored skill. Do not invoke Stash implicitly for ordinary work. --- # Stash -Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. +Use the bundled CLI to search and read external read-only libraries and the +Stash-managed inactive store. Run lifecycle operations only when the user +explicitly requests them. ## Locate the CLI @@ -15,6 +17,10 @@ Resolve `scripts/stash.mjs` relative to this `SKILL.md` and call the resolved ab Classify the text after `$stash`. +- `install `, `import `, or a request to put a skill directly + into Stash as inactive: follow [Lifecycle operations](#lifecycle-operations). +- `archive `, `activate `, `deactivate `, or + `status [name]`: follow [Lifecycle operations](#lifecycle-operations). - `list`: run `stash list --json`. - ` list`: run `stash list --group --json`. - ` list`: run `stash list --source --json`. @@ -83,6 +89,69 @@ node read --resource --format json Read only resources directly required by the selected `SKILL.md`. For a script or binary that must be used by another tool, request `--format path`; do not execute it merely because it was discovered. +## Lifecycle operations + +Treat lifecycle commands as a separate mutation workflow from catalog search. +Do not infer permission from a discovery request. + +### Install inactive + +For a local skill directory, run: + +```text +node install [--source-url ] [--revision ] --json +``` + +The source must contain `SKILL.md` directly. The command copies a verified +snapshot into the managed store and leaves the source unchanged. + +When the user explicitly provides a remote repository source, stage the +requested revision in a newly created temporary directory outside every host +skill discovery path, inspect the selected skill root, then run the local +install command with its source URL and resolved revision. Do not execute +repository content. Do not install it into a host skill folder first. Remove +only the temporary staging directory after a successful managed import. + +### Archive a standalone skill + +Resolve exactly one standalone skill directory and require its host root: + +```text +node archive --host [--scope user] --json +``` + +An explicit directory path still requires `--host`; use `--scope custom +--host-root ` when selecting a non-default discovery root. The source +must be an exact child of that root. Explain that archive removes the source +only after a journaled copy, validation, hash check, and commit. Never archive +a plugin-contained skill; delegate plugin lifecycle to the host. + +### Deploy or withdraw a managed copy + +Run: + +```text +node activate --host [--scope user] --json +node deactivate --host [--scope user] --json +``` + +Report the JSON state as `deployed`, not as proof that the host considers the +skill enabled. Stash does not change Codex `skills.config`, Claude Code +`skillOverrides`, plugin state, or equivalent vendor settings. `deactivate` +removes only a deployment with matching Stash ownership, logical `skillId`, +target, and tree hash; never adopt or delete an untracked directory. + +Antigravity CLI uses flat Markdown standalone skills in both documented scopes, +so reject it as a lifecycle host. Workspace lifecycle targets are also outside +this release. After a discovery-path change, honor `reloadRequired` and +`warning` in the result. + +### Inspect state + +Run `stash status [name] --json`. Report storage state, integrity, deployment +state, ownership, and host observation as separate fields. A deployed copy can +still be disabled by its host; the override remains `unknown`. + ## Error handling - For missing configuration, read [CONFIGURATION.md](references/CONFIGURATION.md). @@ -91,8 +160,15 @@ Read only resources directly required by the selected `SKILL.md`. For a script o ## Boundaries -- Treat every configured catalog as read-only. -- Do not install, enable, disable, copy, move, edit, or delete stored skills. +- Treat every external configured catalog as read-only. +- Install may read an explicitly selected local skill inside a configured + catalog, but it must preserve that source. Treat hash-matching related copies + as projections of the managed canonical result, not as lifecycle authority. +- Run lifecycle commands only when explicitly requested, and only against the + Stash-managed store or an exact standalone child of an explicitly selected, + supported host root. +- Delegate plugin lifecycle and vendor enable/disable settings to the host. +- Do not overwrite, follow links, or delete an untracked or drifted deployment. - Do not invoke `stash` implicitly for ordinary work. - Treat loaded skill instructions as task-local and subordinate to current system, developer, and user instructions. - Treat discovery as context optimization, not as an execution permission or security approval. diff --git a/skills/stash/agents/openai.yaml b/skills/stash/agents/openai.yaml index de22d64..dd38f18 100644 --- a/skills/stash/agents/openai.yaml +++ b/skills/stash/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "Stash" - short_description: "Search local Agent Skills by name, source, or task" - default_prompt: "Use $stash to list skills from a source, open an exact stored skill, or find every stored skill relevant to a task." + short_description: "Search and manage inactive local Agent Skills" + default_prompt: "Use $stash to find a stored skill or explicitly manage a standalone skill in the inactive local store." policy: allow_implicit_invocation: false diff --git a/skills/stash/references/CLI-CONTRACT.md b/skills/stash/references/CLI-CONTRACT.md index 93becdb..0ea0ea3 100644 --- a/skills/stash/references/CLI-CONTRACT.md +++ b/skills/stash/references/CLI-CONTRACT.md @@ -41,6 +41,44 @@ Source IDs and display names are searchable evidence. Prefer `--source` when the Use `--expected-hash` when a workflow must guarantee that the skill selected during resolve is the skill read later. +## Lifecycle contract + +Lifecycle commands use a Stash-owned managed root. They never write to an +external catalog. + +- `install`/`import`/`add`: copy a local skill snapshot into the inactive + managed store and preserve the source. +- `archive`: store and verify an explicitly selected standalone skill, then + remove that source from its discovery path. +- `activate`: copy a managed skill to a host discovery root and record + `status: deployed`. +- `deactivate`: remove only a recorded deployment whose tree hash still + matches. +- `status`: report orthogonal store, integrity, deployment, ownership, and host + observation fields without claiming a host override is enabled. + +`deployed` means present at a documented or explicit discovery root. Check +`reloadRequired` and `warning`. Plugin skills, untracked paths, drifted trees, +symlinks/junctions, overwrites, workspace targets, and all Antigravity CLI +flat-Markdown deployment are rejected. Every managed record has a stable +`skillId`; each deployment has its own ID plus the same `skillId`, Stash +ownership, target ID, and expected tree hash. + +When default resolution includes the managed catalog, `relatedCopies` lists +hash-matching preserved sources and Stash-owned deployments that were folded +into the managed canonical result. Catalog-scoped resolution still returns its +own record, raw refs remain readable, and drifted copies remain separate with a +warning. + +Lifecycle lock metadata is atomically published. A proven-dead owner may be +reclaimed under a single-reclaimer guard; live or malformed ownership fails +closed. If a crashed reclaimer leaves the guard behind, follow +the repository maintenance procedure: stop lifecycle commands, verify both the +recorded PID and all Stash processes are absent, back up `.stash`, move the +guard to an external quarantine, trigger journal preflight with an idempotent +mutation, and verify `status`. Never delete a live/malformed main lock or edit a +journal. + ## Exit codes - `0`: command completed, including a normal `no-match`. diff --git a/skills/stash/references/CONFIGURATION.md b/skills/stash/references/CONFIGURATION.md index 813657e..ddf7af5 100644 --- a/skills/stash/references/CONFIGURATION.md +++ b/skills/stash/references/CONFIGURATION.md @@ -12,6 +12,7 @@ Example: ```yaml version: 1 +managedRoot: "D:/skills/stash-managed" catalogs: - id: personal root: "D:/skills/stash" @@ -37,4 +38,22 @@ Alternatives: Run `stash doctor --json`, then `stash index --json` after configuring a catalog. -The router never edits the configured root. Cache data is stored in the platform cache directory or `STASH_CACHE_DIR`. +No configuration file is required when only the managed inactive store is +used. Its default locations are: + +- Windows: `%LOCALAPPDATA%\stash\managed` +- macOS: `~/Library/Application Support/stash/managed` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/stash/managed` + +Override it with `managedRoot`, `STASH_MANAGED_HOME`, or `--managed-root`. +The managed store is automatically included as catalog id `managed` after it +exists. The router never edits any external configured catalog. Cache data is +stored in the platform cache directory or `STASH_CACHE_DIR`. + +Catalog registration never grants lifecycle write authority. `install` may +read a selected skill inside a configured catalog and preserves its source. +When the managed store is part of the same resolve operation, hash-matching +source and Stash-owned deployment records are folded into the managed result's +`relatedCopies`. Drifted or unrelated records remain separate. A catalog-only +resolve still returns that catalog's own records, and their refs remain +readable. diff --git a/skills/stash/scripts/stash.mjs b/skills/stash/scripts/stash.mjs index 7375d61..c1d2319 100644 --- a/skills/stash/scripts/stash.mjs +++ b/skills/stash/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path5) { - const ctrl = callVisitor(key, node, visitor, path5); + function visit_(key, node, visitor, path7) { + const ctrl = callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visit_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visit_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path5); + const ci = visit_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = visit_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = visit_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path5); + const cv = visit_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path5) { - const ctrl = await callVisitor(key, node, visitor, path5); + async function visitAsync_(key, node, visitor, path7) { + const ctrl = await callVisitor(key, node, visitor, path7); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path5, ctrl); - return visitAsync_(key, ctrl, visitor, path5); + replaceNode(key, path7, ctrl); + return visitAsync_(key, ctrl, visitor, path7); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path5 = Object.freeze(path5.concat(node)); + path7 = Object.freeze(path7.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path5); + const ci = await visitAsync_(i, node.items[i], visitor, path7); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path5 = Object.freeze(path5.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path5); + path7 = Object.freeze(path7.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path7); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path5); + const cv = await visitAsync_("value", node.value, visitor, path7); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path5) { + function callVisitor(key, node, visitor, path7) { if (typeof visitor === "function") - return visitor(key, node, path5); + return visitor(key, node, path7); if (identity.isMap(node)) - return visitor.Map?.(key, node, path5); + return visitor.Map?.(key, node, path7); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path5); + return visitor.Seq?.(key, node, path7); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path5); + return visitor.Pair?.(key, node, path7); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path5); + return visitor.Scalar?.(key, node, path7); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path5); + return visitor.Alias?.(key, node, path7); return void 0; } - function replaceNode(key, path5, node) { - const parent = path5[path5.length - 1]; + function replaceNode(key, path7, node) { + const parent = path7[path7.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path5, value) { + function collectionFromPath(schema, path7, value) { let v = value; - for (let i = path5.length - 1; i >= 0; --i) { - const k = path5[i]; + for (let i = path7.length - 1; i >= 0; --i) { + const k = path7[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path5) => path5 == null || typeof path5 === "object" && !!path5[Symbol.iterator]().next().done; + var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path5, value) { - if (isEmptyPath(path5)) + addIn(path7, value) { + if (isEmptyPath(path7)) this.add(value); else { - const [key, ...rest] = path5; + const [key, ...rest] = path7; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - const [key, ...rest] = path5; + deleteIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - const [key, ...rest] = path5; + getIn(path7, keepScalar) { + const [key, ...rest] = path7; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path5) { - const [key, ...rest] = path5; + hasIn(path7) { + const [key, ...rest] = path7; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - const [key, ...rest] = path5; + setIn(path7, value) { + const [key, ...rest] = path7; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path5, value) { + addIn(path7, value) { if (assertCollection(this.contents)) - this.contents.addIn(path5, value); + this.contents.addIn(path7, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path5) { - if (Collection.isEmptyPath(path5)) { + deleteIn(path7) { + if (Collection.isEmptyPath(path7)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path5) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path5, keepScalar) { - if (Collection.isEmptyPath(path5)) + getIn(path7, keepScalar) { + if (Collection.isEmptyPath(path7)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path5, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path5) { - if (Collection.isEmptyPath(path5)) + hasIn(path7) { + if (Collection.isEmptyPath(path7)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path5) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path5, value) { - if (Collection.isEmptyPath(path5)) { + setIn(path7, value) { + if (Collection.isEmptyPath(path7)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path5), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path5, value); + this.contents.setIn(path7, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path5) => { + visit.itemAtPath = (cst, path7) => { let item = cst; - for (const [field, index] of path5) { + for (const [field, index] of path7) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path5) => { - const parent = visit.itemAtPath(cst, path5.slice(0, -1)); - const field = path5[path5.length - 1][0]; + visit.parentCollection = (cst, path7) => { + const parent = visit.itemAtPath(cst, path7.slice(0, -1)); + const field = path7[path7.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path5, item, visitor) { - let ctrl = visitor(item, path5); + function _visit(path7, item, visitor) { + let ctrl = visitor(item, path7); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path5.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path5); + ctrl = ctrl(item, path7); } } - return typeof ctrl === "function" ? ctrl(item, path5) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; } exports.visit = visit; } @@ -7263,7 +7263,7 @@ var require_public_api = __commonJS({ } return doc; } - function parse3(src, reviver, options) { + function parse4(src, reviver, options) { let _reviver = void 0; if (typeof reviver === "function") { _reviver = reviver; @@ -7304,7 +7304,7 @@ var require_public_api = __commonJS({ return value.toString(options); return new Document.Document(value, _replacer, options).toString(options); } - exports.parse = parse3; + exports.parse = parse4; exports.parseAllDocuments = parseAllDocuments; exports.parseDocument = parseDocument; exports.stringify = stringify; @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile3, realpath as realpath2, stat as stat2 } from "node:fs/promises"; -import path4 from "node:path"; +import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; +import path5 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7538,6 +7538,33 @@ function platformCachePath() { "stash" ); } +function platformManagedPath() { + if (process.env.STASH_MANAGED_HOME) { + return path.resolve(process.env.STASH_MANAGED_HOME); + } + const currentPlatform = platform(); + if (currentPlatform === "win32") { + return path.join( + process.env.LOCALAPPDATA ?? path.join(homedir(), "AppData", "Local"), + "stash", + "managed" + ); + } + if (currentPlatform === "darwin") { + return path.join( + homedir(), + "Library", + "Application Support", + "stash", + "managed" + ); + } + return path.join( + process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share"), + "stash", + "managed" + ); +} function safeCatalogSegment(value) { return value.replace(/[^a-zA-Z0-9._-]/gu, "_"); } @@ -7668,16 +7695,21 @@ function parseDefaults(value, overrides) { async function loadConfiguration(options) { const cacheDir = path2.resolve(options.cacheDir ?? platformCachePath()); if (options.catalogs) { - const catalogs = options.catalogs.map((catalog) => ({ + let catalogs2 = options.catalogs.map((catalog) => ({ ...catalog, root: path2.resolve(catalog.root) })); - validateUniqueCatalogIds(catalogs); + const managedRoot2 = options.managedRoot ? path2.resolve(options.managedRoot) : void 0; + if (managedRoot2) { + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + } + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + ...managedRoot2 ? { managedRoot: managedRoot2 } : {} }, cacheDir }; @@ -7694,23 +7726,29 @@ async function loadConfiguration(options) { 5 ); } - if (!Array.isArray(parsed.catalogs)) { + if (parsed.catalogs !== void 0 && !Array.isArray(parsed.catalogs)) { throw new StashError( "invalid-config", - `Config "${configPath}" must define a catalogs array.`, + `Config "${configPath}" catalogs must be an array.`, 2 ); } const baseDir = path2.dirname(configPath); - const catalogs = parsed.catalogs.map( + let catalogs2 = (Array.isArray(parsed.catalogs) ? parsed.catalogs : []).map( (catalog, index) => parseCatalog(catalog, baseDir, index) ); - validateUniqueCatalogIds(catalogs); + const configuredManagedRoot = typeof parsed.managedRoot === "string" && parsed.managedRoot.trim() ? path2.resolve(baseDir, parsed.managedRoot) : void 0; + const managedRoot2 = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? configuredManagedRoot ?? platformManagedPath() + ); + catalogs2 = await includeManagedCatalog(catalogs2, managedRoot2); + validateUniqueCatalogIds(catalogs2); return { configuration: { version: 1, - catalogs, - defaults: parseDefaults(parsed.defaults, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(parsed.defaults, options.defaults), + managedRoot: managedRoot2 }, cacheDir, configPath @@ -7728,29 +7766,92 @@ async function loadConfiguration(options) { ); } } + const managedRoot = path2.resolve( + options.managedRoot ?? process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ); if (process.env.STASH_HOME) { + const catalogs2 = await includeManagedCatalog( + [ + { + id: "default", + root: path2.resolve(process.env.STASH_HOME), + enabled: true, + trust: "unreviewed", + followSymlinks: false + } + ], + managedRoot + ); return { configuration: { version: 1, - catalogs: [ - { - id: "default", - root: path2.resolve(process.env.STASH_HOME), - enabled: true, - trust: "unreviewed", - followSymlinks: false - } - ], - defaults: parseDefaults(void 0, options.defaults) + catalogs: catalogs2, + defaults: parseDefaults(void 0, options.defaults), + managedRoot }, cacheDir }; } - throw new StashError( - "config-not-found", - `Stash config was not found at "${configPath}". Set STASH_CONFIG, STASH_HOME, or pass --config.`, - 2 - ); + if (explicitConfig) { + throw new StashError( + "config-not-found", + `Stash config was not found at "${configPath}".`, + 2 + ); + } + const catalogs = await includeManagedCatalog([], managedRoot); + return { + configuration: { + version: 1, + catalogs, + defaults: parseDefaults(void 0, options.defaults), + managedRoot + }, + cacheDir + }; +} +async function includeManagedCatalog(catalogs, managedRoot) { + if (catalogs.some((catalog) => catalog.id === "managed")) { + throw new StashError( + "invalid-config", + 'Catalog id "managed" is reserved for the Stash-managed store.', + 2 + ); + } + const normalizedManagedRoot = path2.resolve(managedRoot); + if (catalogs.some((catalog) => { + const normalizedCatalogRoot = path2.resolve(catalog.root); + return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; + })) { + return catalogs; + } + try { + const info = await stat(managedRoot); + if (!info.isDirectory()) { + throw new StashError( + "invalid-config", + `Managed root is not a directory: "${managedRoot}".`, + 2 + ); + } + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return catalogs; + } + throw error; + } + return [ + ...catalogs, + { + id: "managed", + root: managedRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ]; } function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); @@ -7775,7 +7876,7 @@ import { readdir, realpath, rename, - stat, + stat as stat2, unlink, writeFile } from "node:fs/promises"; @@ -7878,7 +7979,7 @@ function parseSidecar(source, catalog) { }; } async function readBounded(filePath, maxBytes) { - const info = await stat(filePath); + const info = await stat2(filePath); if (info.size > maxBytes) { throw new Error(`File exceeds ${maxBytes} bytes.`); } @@ -7889,7 +7990,7 @@ async function loadManifest(root, catalog, warnings) { let input = {}; let fingerprintPart = ""; try { - const manifestInfo = await stat(manifestPath); + const manifestInfo = await stat2(manifestPath); input = asObject2((0, import_yaml2.parse)(await readBounded(manifestPath, MAX_SIDECAR_BYTES))); fingerprintPart = [ "stash.catalog.yaml", @@ -7961,7 +8062,7 @@ async function discoverCandidates(catalog) { 4 ); } - const rootInfo = await stat(root); + const rootInfo = await stat2(root); if (!rootInfo.isDirectory()) { throw new StashError( "catalog-unavailable", @@ -7987,13 +8088,13 @@ async function discoverCandidates(catalog) { ); if (skillEntry) { const skillFile = path3.join(directory, skillEntry.name); - const info = await stat(skillFile); + const info = await stat2(skillFile); const sidecarPath = path3.join(directory, "stash.meta.yaml"); let sidecarFile; let sidecarModifiedMs; let sidecarSize; try { - const sidecarInfo = await stat(sidecarPath); + const sidecarInfo = await stat2(sidecarPath); if (sidecarInfo.isFile()) { sidecarFile = sidecarPath; sidecarModifiedMs = sidecarInfo.mtimeMs; @@ -8038,7 +8139,7 @@ async function discoverCandidates(catalog) { }); continue; } - const targetInfo = await stat(target); + const targetInfo = await stat2(target); if (targetInfo.isDirectory()) { await walk(target, depth + 1); } @@ -8234,7 +8335,7 @@ async function acquireLock(lockPath) { throw error; } try { - const lockInfo = await stat(lockPath); + const lockInfo = await stat2(lockPath); if (Date.now() - lockInfo.mtimeMs > 3e4) { await unlink(lockPath); continue; @@ -8254,6 +8355,225 @@ async function acquireLock(lockPath) { } } +// src/internal/managed-projection.ts +import { + lstat, + readFile as readFile3, + readdir as readdir2, + realpath as realpath2, + stat as stat3 +} from "node:fs/promises"; +import { platform as platform2 } from "node:os"; +import path4 from "node:path"; +var MAX_FILES = 1e4; +var MAX_TOTAL_BYTES = 256 * 1024 * 1024; +function pathIdentity(value) { + const normalized = path4.resolve(value).normalize("NFKC"); + return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function validRecord(value) { + if (!value || typeof value !== "object") { + return false; + } + const record = value; + return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); +} +async function treeHash(rootInput) { + try { + const rootInfo = await lstat(rootInput); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + return void 0; + } + const root = await realpath2(rootInput); + const entries = []; + let files = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new Error("linked tree"); + } + if (before.isDirectory()) { + const canonical = await realpath2(childPath); + if (!isPathInside(root, canonical)) { + throw new Error("tree escape"); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new Error("special file"); + } + files += 1; + totalBytes += before.size; + if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new Error("tree too large"); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new Error("tree changed"); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return sha256(fingerprint); + } catch { + return void 0; + } +} +function relatedCopy(target, record) { + if (target.kind === "deployment" && target.deployment) { + return { + kind: "deployment", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId, + targetId: target.deployment.targetId, + host: target.deployment.host, + scope: target.deployment.scope + }; + } + return { + kind: "source", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId + }; +} +async function projectManagedCopies(sourceIndexes, managedRoot) { + if (!managedRoot || !sourceIndexes.some((index) => index.catalogId === "managed")) { + return { indexes: sourceIndexes, fingerprintPart: "" }; + } + const indexes = sourceIndexes.map((index) => ({ + ...index, + records: index.records.map((record) => ({ ...record })), + warnings: [...index.warnings] + })); + const managedIndex = indexes.find((index) => index.catalogId === "managed"); + if (!managedIndex) { + return { indexes, fingerprintPart: "" }; + } + let recordFiles; + const recordsRoot = path4.join(managedRoot, ".stash", "records"); + try { + recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + } catch { + return { indexes, fingerprintPart: "" }; + } + const targets = /* @__PURE__ */ new Map(); + const managedRecords = /* @__PURE__ */ new Map(); + for (const file of recordFiles) { + try { + const parsed = JSON.parse( + await readFile3(path4.join(recordsRoot, file), "utf8") + ); + if (!validRecord(parsed)) { + throw new Error("invalid record"); + } + managedRecords.set(parsed.name, parsed); + targets.set(pathIdentity(parsed.source.location), { + kind: "source", + record: parsed, + expectedTreeHash: parsed.treeHash + }); + for (const deployment of parsed.deployments) { + if (deployment.ownership === "stash" && deployment.skillId === parsed.skillId) { + targets.set(pathIdentity(deployment.path), { + kind: "deployment", + record: parsed, + expectedTreeHash: deployment.treeHash, + deployment + }); + } + } + } catch { + managedIndex.warnings.push({ + code: "invalid-lifecycle-record", + message: `Ignored invalid lifecycle projection record "${file}".`, + path: `.stash/records/${file}` + }); + } + } + const canonicalBySkillId = /* @__PURE__ */ new Map(); + for (const record of managedIndex.records) { + const managedRecord = managedRecords.get(record.name); + if (managedRecord) { + record.managedSkillId = managedRecord.skillId; + record.relatedCopies = []; + canonicalBySkillId.set(managedRecord.skillId, record); + } + } + const events = []; + for (const index of indexes) { + if (index.catalogId === "managed") { + continue; + } + const visible = []; + for (const record of index.records) { + const skillRoot = path4.dirname( + path4.resolve(index.root, record.relativeSkillFile) + ); + const target = targets.get(pathIdentity(skillRoot)); + const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; + if (!target || !canonical) { + visible.push(record); + continue; + } + const actualTreeHash = await treeHash(skillRoot); + if (actualTreeHash !== target.expectedTreeHash) { + visible.push(record); + index.warnings.push({ + code: "managed-copy-drift", + message: `Managed-related ${target.kind} "${record.ref}" drifted and remains a separate result.`, + ref: record.ref, + path: record.relativeSkillFile + }); + events.push(`drift:${record.ref}:${actualTreeHash ?? "unavailable"}`); + continue; + } + const related = relatedCopy(target, record); + canonical.relatedCopies = [...canonical.relatedCopies ?? [], related]; + events.push( + `fold:${record.ref}:${target.record.skillId}:${target.kind}:${actualTreeHash}` + ); + } + index.records = visible; + } + for (const record of managedIndex.records) { + if (record.relatedCopies) { + record.relatedCopies.sort( + (left, right) => `${left.kind}:${left.catalogId}:${left.ref}`.localeCompare( + `${right.kind}:${right.catalogId}:${right.ref}`, + "en" + ) + ); + } + } + return { + indexes, + fingerprintPart: sha256(events.sort().join("\n")) + }; +} + // src/internal/search.ts var ROUTING_PROFILE_VERSION = 4; var FIELD_WEIGHTS = { @@ -8583,7 +8903,9 @@ function toListedSkill(record) { compatibility: record.compatibility, trust: record.trust, ...Object.keys(record.source).length > 0 ? { source: record.source } : {}, - contentHash: record.contentHash + contentHash: record.contentHash, + ...record.managedSkillId ? { managedSkillId: record.managedSkillId } : {}, + ...record.relatedCopies && record.relatedCopies.length > 0 ? { relatedCopies: record.relatedCopies } : {} }; } @@ -8623,7 +8945,7 @@ var StashCatalogImplementation = class { this.#cacheDir = cacheDir; this.#now = now; } - async #loadIndexes(catalogIds) { + async #loadIndexes(catalogIds, applyManagedProjection = true) { const selected = this.#selectRegistrations(catalogIds); const indexes = []; for (const catalog of selected) { @@ -8635,12 +8957,18 @@ var StashCatalogImplementation = class { ); indexes.push(loaded.index); } + const projected = applyManagedProjection ? await projectManagedCopies(indexes, this.#configuration.managedRoot) : { indexes, fingerprintPart: "" }; return { - indexes, + indexes: projected.indexes, registrations: selected, - warnings: indexes.flatMap((index) => index.warnings), + warnings: projected.indexes.flatMap((index) => index.warnings), fingerprint: sha256( - indexes.map((index) => `${index.catalogId}:${index.fingerprint}`).sort().join("\n") + [ + ...projected.indexes.map( + (index) => `${index.catalogId}:${index.fingerprint}` + ), + projected.fingerprintPart + ].sort().join("\n") ) }; } @@ -8900,7 +9228,7 @@ var StashCatalogImplementation = class { resource }; } - const loaded = await this.#loadIndexes(); + const loaded = await this.#loadIndexes(void 0, false); let record; let index; for (const candidateIndex of loaded.indexes) { @@ -8929,10 +9257,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path4.resolve(skillRoot, normalizedResource); + const candidatePath = path5.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8944,7 +9272,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath2(candidatePath); + resolvedPath = await realpath3(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -8953,7 +9281,7 @@ var StashCatalogImplementation = class { resource }; } - const info = await stat2(resolvedPath); + const info = await stat4(resolvedPath); if (!info.isFile()) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9005,7 +9333,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile3(resolvedPath); + const buffer = await readFile4(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9120,140 +9448,1532 @@ async function createStashCatalog(options = {}) { ); } -// src/cli.ts -function parseArguments(argv) { - const [command, ...rest] = argv; - const positionals = []; - const flags2 = /* @__PURE__ */ new Map(); - for (let index = 0; index < rest.length; index += 1) { - const current = rest[index]; - if (!current) { - continue; - } - if (!current.startsWith("--")) { - positionals.push(current); - continue; - } - const [rawName, inlineValue] = current.slice(2).split("=", 2); - const name = rawName ?? ""; - let value = inlineValue; - if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { - value = rest[index + 1]; - index += 1; - } - const values = flags2.get(name) ?? []; - values.push(value ?? "true"); - flags2.set(name, values); - } - return { ...command ? { command } : {}, positionals, flags: flags2 }; -} -function flag(args, name) { - return args.flags.get(name)?.at(-1); -} -function flags(args, name) { - const values = args.flags.get(name); - return values && values.length > 0 ? values : void 0; +// src/stash-lifecycle.ts +var import_yaml3 = __toESM(require_dist(), 1); +import { randomUUID } from "node:crypto"; +import { + lstat as lstat2, + mkdir as mkdir2, + readFile as readFile5, + readdir as readdir3, + realpath as realpath4, + rename as rename2, + rm, + stat as stat5, + unlink as unlink2, + writeFile as writeFile2 +} from "node:fs/promises"; +import { homedir as homedir2, platform as platform3 } from "node:os"; +import path6 from "node:path"; +var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +var MAX_FILES2 = 1e4; +var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; +var STORE_SCHEMA_VERSION = 1; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +function asObject3(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } -function booleanFlag(args, name) { - return args.flags.has(name) && flag(args, name) !== "false"; +function compatibilityState2(value) { + return value === "supported" || value === "partial" || value === "unsupported" || value === "unknown" ? value : "unknown"; } -function numberFlag(args, name) { - const value = flag(args, name); - if (value === void 0) { - return void 0; +function parseSkillMetadata(snapshot) { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec( + snapshot.skillSource.replace(/^\uFEFF/u, "") + ); + if (!match?.[1]) { + throw new StashError( + "invalid-skill", + "SKILL.md must contain closed YAML frontmatter.", + 3 + ); } - const parsed = Number(value); - if (!Number.isFinite(parsed)) { + const metadata = asObject3((0, import_yaml3.parse)(match[1])); + if (typeof metadata.name !== "string" || metadata.name.length > 64 || !NAME_PATTERN2.test(metadata.name)) { throw new StashError( - "invalid-argument", - `--${name} must be a number.`, - 2 + "invalid-skill", + "SKILL.md name must use 1-64 lowercase letters, digits, or hyphens.", + 3 ); } - return parsed; -} -function createOptions(args) { - const root = flag(args, "root"); - const catalogId = flag(args, "root-id") ?? "default"; - const configPath = flag(args, "config"); - const cacheDir = flag(args, "cache-dir"); - const catalogs = root ? [ - { - id: catalogId, - root, - enabled: true, - trust: "unreviewed", - followSymlinks: false, - compatibility: ["codex", "claude-code", "antigravity"] + if (typeof metadata.description !== "string" || metadata.description.trim() === "" || metadata.description.length > 1024) { + throw new StashError( + "invalid-skill", + "SKILL.md description must be a non-empty string up to 1024 characters.", + 3 + ); + } + let compatibility = {}; + if (snapshot.sidecarSource) { + const sidecar = asObject3((0, import_yaml3.parse)(snapshot.sidecarSource)); + if (sidecar.schemaVersion !== void 0 && sidecar.schemaVersion !== 1) { + throw new StashError( + "invalid-skill", + "stash.meta.yaml must use schemaVersion: 1.", + 3 + ); } - ] : void 0; + compatibility = asObject3(sidecar.compatibility); + } return { - ...configPath ? { configPath } : {}, - ...cacheDir ? { cacheDir } : {}, - ...catalogs ? { catalogs } : {} + name: metadata.name, + compatibility: { + codex: compatibilityState2(compatibility.codex), + "claude-code": compatibilityState2(compatibility["claude-code"]), + antigravity: compatibilityState2(compatibility.antigravity) + } }; } -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)} -`); +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); } -function printResolve(result) { - if (result.status === "no-match") { - process.stdout.write( - result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. -` : "No matching skills.\n" +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot end in a dot or space: "${segment}".`, + 3 ); - return; - } - if (result.status !== "ok" && result.status !== "ambiguous-exact") { - process.stdout.write(`Stash: ${result.status} -`); - return; - } - process.stdout.write( - `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" - ); - let currentScope = ""; - for (const match of result.matches) { - const group = match.group ?? "(ungrouped)"; - const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; - const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; - if (scope !== currentScope) { - currentScope = scope; - process.stdout.write(` -${scope} -`); - } - const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; - process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} -`); - process.stdout.write(` ref: ${match.ref} -`); } - if (result.page.nextCursor) { - process.stdout.write(` -next_cursor: ${result.page.nextCursor} -`); + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot use the reserved name "${segment}".`, + 3 + ); } } -function usage() { - return `Stash \u2014 on-demand search for local Agent Skills - -Usage: - stash exact [--source ] [--group ] [--catalog ] [--json] - stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] - stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] +async function snapshotTree(sourceRoot) { + const rootInput = path6.resolve(sourceRoot); + const rootInfo = await lstat2(rootInput).catch((error) => { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${rootInput}": ${String(error)}`, + 4 + ); + }); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new StashError( + "unsafe-skill-tree", + `Skill root must be a real directory, not a link: "${rootInput}".`, + 3 + ); + } + const root = await realpath4(rootInput); + const entries = []; + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + let skillSource; + let sidecarSource; + async function walk(directory, relativeDirectory) { + const children = await readdir3(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new StashError( + "unsafe-skill-tree", + `Case-insensitive path collision at "${relativePath}".`, + 3 + ); + } + pathKeys.add(key); + const childPath = path6.join(directory, child.name); + const before = await lstat2(childPath); + if (before.isSymbolicLink()) { + throw new StashError( + "unsafe-skill-tree", + `Symlinks and junctions are not allowed: "${relativePath}".`, + 3 + ); + } + if (before.isDirectory()) { + const canonical = await realpath4(childPath); + if (!isPathInside(root, canonical)) { + throw new StashError( + "unsafe-skill-tree", + `Directory escapes the skill root: "${relativePath}".`, + 3 + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new StashError( + "unsafe-skill-tree", + `Only regular files and directories are allowed: "${relativePath}".`, + 3 + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { + throw new StashError( + "skill-too-large", + `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, + 3 + ); + } + const content = await readFile5(childPath); + const after = await stat5(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new StashError( + "source-changed", + `Skill changed while it was being read: "${relativePath}".`, + 4 + ); + } + const contentHash = sha256(content); + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash + }); + if (relativePath === "SKILL.md") { + skillSource = content.toString("utf8"); + } else if (relativePath === "stash.meta.yaml") { + sidecarSource = content.toString("utf8"); + } + } + } + await walk(root, ""); + if (skillSource === void 0) { + throw new StashError( + "invalid-skill", + `Skill root must contain SKILL.md: "${root}".`, + 3 + ); + } + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + skillSource, + ...sidecarSource !== void 0 ? { sidecarSource } : {} + }; +} +async function copySnapshot(snapshot, destination) { + await mkdir2(destination, { recursive: false }); + for (const entry of snapshot.entries) { + const relative = entry.relativePath.split("/").join(path6.sep); + const target = path6.join(destination, relative); + if (entry.kind === "directory") { + await mkdir2(target, { recursive: false }); + continue; + } + const source = path6.join(snapshot.root, relative); + const content = await readFile5(source); + if (sha256(content) !== entry.contentHash) { + throw new StashError( + "source-changed", + `Skill changed while it was being copied: "${entry.relativePath}".`, + 4 + ); + } + await writeFile2(target, content, { flag: "wx" }); + } +} +async function pathType(target) { + try { + const info = await lstat2(target); + if (info.isSymbolicLink()) { + return "link"; + } + if (info.isDirectory()) { + return "directory"; + } + return "other"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return "missing"; + } + throw error; + } +} +function samePath(left, right) { + const normalizedLeft = path6.resolve(left).normalize("NFKC"); + const normalizedRight = path6.resolve(right).normalize("NFKC"); + return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; +} +function pathIdentity2(value) { + const normalized = path6.resolve(value).normalize("NFKC"); + return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} +function targetIdentity(target) { + return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; +} +function resolveHostRoot(target) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace") { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + if (target.root) { + return { + host: target.host, + scope: target.scope ?? "custom", + root: path6.resolve(target.root) + }; + } + const scope = target.scope ?? "user"; + if (scope === "custom") { + throw new StashError( + "invalid-argument", + "A custom lifecycle target requires root.", + 2 + ); + } + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path6.join(homedir2(), ".gemini", "config", "skills") + }; + } +} +async function isPluginContained(source) { + let current = path6.dirname(source); + for (let depth = 0; depth < 12; depth += 1) { + const markers = [ + path6.join(current, ".claude-plugin", "plugin.json"), + path6.join(current, ".codex-plugin", "plugin.json"), + path6.join(current, "plugin.json") + ]; + for (const marker of markers) { + if (await pathType(marker) !== "missing") { + return true; + } + } + const parent = path6.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return false; +} +var StashLifecycleImplementation = class { + #managedRoot; + #now; + constructor(managedRoot, now) { + this.#managedRoot = path6.resolve(managedRoot); + this.#now = now; + } + #metadataRoot() { + return path6.join(this.#managedRoot, ".stash"); + } + #recordPath(name) { + return path6.join(this.#metadataRoot(), "records", `${name}.json`); + } + async #ensureLayout() { + await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + try { + await writeFile2( + manifestPath, + "schemaVersion: 1\nid: managed\ndefaults:\n trust: unreviewed\n compatibility:\n - codex\n - claude-code\n - antigravity\nskillDiscovery:\n ignore:\n - .stash\n", + { encoding: "utf8", flag: "wx" } + ); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "EEXIST") { + throw error; + } + } + } + #journalPath(operationId) { + return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + } + async #writeJournal(journal) { + const finalPath = this.#journalPath(journal.operationId); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(journal, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #advanceArchiveJournal(journal, stage) { + const next = { ...journal, stage }; + await this.#writeJournal(next); + journal.stage = stage; + } + async #removeIncompleteManaged(journal) { + if (journal.managedExistedBefore) { + return; + } + const record = await this.#readRecord(journal.name); + if (record && record.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed record changed during archive recovery: "${journal.name}".`, + 4 + ); + } + if (await pathType(journal.managedPath) === "directory") { + const snapshot = await snapshotTree(journal.managedPath); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed tree changed during archive recovery: "${journal.managedPath}".`, + 4 + ); + } + await rm(journal.managedPath, { recursive: true, force: false }); + } + await unlink2(this.#recordPath(journal.name)).catch((error) => { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + }); + } + #validateArchiveJournal(journal, journalPath) { + const stages = /* @__PURE__ */ new Set([ + "started", + "managed-committed", + "source-tombstoned", + "archive-committed" + ]); + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + const expectedManagedPath = path6.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + `.stash-archive-${journal.name}-` + ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5 + ); + } + } + async #recoverArchiveJournal(journal, journalPath) { + const sourceType = await pathType(journal.source); + const tombstoneType = await pathType(journal.tombstone); + if (journal.stage === "archive-committed") { + if (tombstoneType !== "missing") { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone is not a directory: "${journal.tombstone}".`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rm(journal.tombstone, { recursive: true, force: false }); + } + await unlink2(journalPath); + return; + } + const sourceWasMoved = journal.stage === "source-tombstoned" || sourceType === "missing" && tombstoneType !== "missing"; + if (sourceWasMoved) { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive source and tombstone cannot be reconciled for "${journal.name}".`, + 4 + ); + } + if (sourceType !== "missing") { + throw new StashError( + "archive-restore-conflict", + `Archive recovery preserved "${journal.tombstone}" because "${journal.source}" is occupied.`, + 4 + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4 + ); + } + await rename2(journal.tombstone, journal.source); + } + await this.#removeIncompleteManaged(journal); + await unlink2(journalPath); + } + async #recoverJournals() { + const journalRoot = path6.join(this.#metadataRoot(), "journal"); + const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + for (const file of files) { + const journalPath = path6.join(journalRoot, file); + let journal; + try { + journal = JSON.parse(await readFile5(journalPath, "utf8")); + } catch (error) { + throw new StashError( + "invalid-lifecycle-journal", + `Unable to read lifecycle journal "${journalPath}": ${String(error)}`, + 5 + ); + } + this.#validateArchiveJournal(journal, journalPath); + await this.#recoverArchiveJournal(journal, journalPath); + } + } + async #readLockOwner(lockPath) { + let owner; + try { + owner = JSON.parse( + await readFile5(path6.join(lockPath, "owner.json"), "utf8") + ); + } catch (error) { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is missing or malformed at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + if (owner.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(owner.ownerToken) || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.createdAt !== "string") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is invalid at "${lockPath}"; refusing automatic recovery.`, + 4 + ); + } + return owner; + } + #ownerIsAlive(owner) { + try { + process.kill(owner.pid, 0); + return true; + } catch { + return false; + } + } + async #publishLock(lockPath, owner) { + const temporaryPath = path6.join( + this.#metadataRoot(), + `.lifecycle-lock-${owner.ownerToken}.tmp` + ); + await mkdir2(temporaryPath, { recursive: false }); + try { + await writeFile2( + path6.join(temporaryPath, "owner.json"), + `${JSON.stringify(owner)} +`, + { encoding: "utf8", flag: "wx" } + ); + try { + await rename2(temporaryPath, lockPath); + return true; + } catch (error) { + if (await pathType(lockPath) === "directory") { + return false; + } + throw error; + } + } finally { + await rm(temporaryPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #reclaimDeadLock(lockPath) { + const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + try { + await mkdir2(guardPath, { recursive: false }); + } catch (error) { + if (await pathType(guardPath) === "directory") { + throw new StashError( + "lifecycle-busy", + `Another process is checking stale lifecycle ownership at "${lockPath}". If no Stash process is running, follow the documented reclaim-guard repair procedure.`, + 4 + ); + } + throw error; + } + try { + if (await pathType(lockPath) === "missing") { + return; + } + if (await pathType(lockPath) !== "directory") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock is not a directory at "${lockPath}".`, + 4 + ); + } + const owner = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(owner)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + const reclaimedPath = path6.join( + this.#metadataRoot(), + `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` + ); + await rename2(lockPath, reclaimedPath); + const reclaimedOwner = await this.#readLockOwner(reclaimedPath); + if (reclaimedOwner.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-corrupt", + "Lifecycle lock ownership changed during stale recovery.", + 4 + ); + } + await rm(reclaimedPath, { recursive: true, force: false }); + } finally { + await rm(guardPath, { recursive: true, force: true }).catch( + () => void 0 + ); + } + } + async #acquireLock(lockPath) { + const owner = { + schemaVersion: 1, + ownerToken: randomUUID(), + pid: process.pid, + createdAt: new Date(this.#now()).toISOString() + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await this.#publishLock(lockPath, owner)) { + return owner; + } + const existing = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(existing)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4 + ); + } + await this.#reclaimDeadLock(lockPath); + } + throw new StashError( + "lifecycle-busy", + `Lifecycle ownership changed while acquiring "${lockPath}".`, + 4 + ); + } + async #releaseLock(lockPath, owner) { + const current = await this.#readLockOwner(lockPath); + if (current.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-lost", + `Lifecycle lock ownership changed at "${lockPath}".`, + 4 + ); + } + await rm(lockPath, { recursive: true, force: false }); + } + async #withLock(operation) { + await this.#ensureLayout(); + const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const owner = await this.#acquireLock(lockPath); + try { + await this.#recoverJournals(); + return await operation(); + } finally { + await this.#releaseLock(lockPath, owner); + } + } + async #readRecord(name) { + const recordPath = this.#recordPath(name); + try { + const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + )) { + throw new Error("invalid lifecycle record shape"); + } + return parsed; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + return void 0; + } + throw new StashError( + "invalid-lifecycle-record", + `Unable to read lifecycle record "${recordPath}": ${String(error)}`, + 5 + ); + } + } + async #writeRecord(record) { + const finalPath = this.#recordPath(record.name); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile2(temporaryPath, `${JSON.stringify(record, null, 2)} +`, { + encoding: "utf8", + flag: "wx" + }); + try { + await rename2(temporaryPath, finalPath); + } catch (error) { + await unlink2(temporaryPath).catch(() => void 0); + throw error; + } + } + async #assertSourceBoundary(source) { + const canonicalSource = await realpath4(source); + const canonicalManaged = await realpath4(this.#managedRoot); + if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { + throw new StashError( + "unsafe-source", + "A lifecycle source cannot be inside, contain, or equal the managed root.", + 3 + ); + } + } + async #canonicalHostRoot(root, create, allowMissing = false) { + let type = await pathType(root); + if (type === "missing" && create) { + await mkdir2(root, { recursive: true }); + type = await pathType(root); + } + if (type === "missing" && allowMissing) { + return path6.resolve(root); + } + if (type !== "directory") { + throw new StashError( + "unsafe-host-root", + `Host root must be a real directory, not a link or special path: "${root}".`, + 3 + ); + } + return realpath4(root); + } + async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { + await this.#assertSourceBoundary(source); + const snapshot = await snapshotTree(source); + if (expectedTreeHash && snapshot.treeHash !== expectedTreeHash) { + throw new StashError( + "source-changed", + `Skill changed before its managed copy could be committed: "${source}".`, + 4 + ); + } + const metadata = parseSkillMetadata(snapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const existingType = await pathType(managedPath); + if (existingType !== "missing") { + if (existingType !== "directory") { + throw new StashError( + "managed-conflict", + `Managed target is not a directory: "${managedPath}".`, + 3 + ); + } + const existingRecord = await this.#readRecord(metadata.name); + const existingSnapshot = await snapshotTree(managedPath); + if (existingRecord && existingRecord.treeHash === snapshot.treeHash && existingSnapshot.treeHash === snapshot.treeHash) { + return { + record: existingRecord, + managedPath, + created: false + }; + } + throw new StashError( + "managed-conflict", + `Managed skill "${metadata.name}" already exists with different content or metadata.`, + 3 + ); + } + if (await this.#readRecord(metadata.name)) { + throw new StashError( + "managed-conflict", + `Lifecycle metadata exists without its managed skill: "${metadata.name}".`, + 3 + ); + } + const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + let committed = false; + try { + await copySnapshot(snapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== snapshot.treeHash) { + throw new StashError( + "copy-verification-failed", + `Staged tree hash differs for "${metadata.name}".`, + 4 + ); + } + const timestamp = new Date(this.#now()).toISOString(); + const record = { + schemaVersion: STORE_SCHEMA_VERSION, + skillId: randomUUID(), + name: metadata.name, + treeHash: snapshot.treeHash, + source: { + kind, + location: snapshot.root, + importedAt: timestamp, + ...sourceUrl ? { url: sourceUrl } : {}, + ...revision ? { revision } : {} + }, + compatibility: metadata.compatibility, + deployments: [], + lastValidatedAt: timestamp + }; + await rename2(stage, managedPath); + committed = true; + try { + await this.#writeRecord(record); + } catch (error) { + await rm(managedPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { record, managedPath, created: true }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + } + async install(request) { + return this.#withLock(async () => { + const stored = await this.#storeSource( + path6.resolve(request.source), + "local-import", + request.sourceUrl, + request.revision + ); + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash + }; + }); + } + async archive(request) { + return this.#withLock(async () => { + const resolvedTarget = resolveHostRoot(request.target); + resolvedTarget.root = await this.#canonicalHostRoot( + resolvedTarget.root, + false + ); + const looksLikeName = NAME_PATTERN2.test(request.source); + const source = path6.resolve( + looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + ); + if (!samePath(path6.dirname(source), resolvedTarget.root)) { + throw new StashError( + "unsafe-archive-source", + `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, + 3 + ); + } + if (await isPluginContained(source)) { + throw new StashError( + "plugin-lifecycle-delegated", + "Plugin-contained skills must be managed by the host plugin manager.", + 3 + ); + } + await this.#assertSourceBoundary(source); + const sourceSnapshot = await snapshotTree(source); + const metadata = parseSkillMetadata(sourceSnapshot); + const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; + const tombstoneParent = path6.dirname(resolvedTarget.root); + await mkdir2(tombstoneParent, { recursive: true }); + const tombstone = path6.join( + tombstoneParent, + `.stash-archive-${metadata.name}-${randomUUID()}` + ); + const journal = { + schemaVersion: 1, + operationId: randomUUID(), + stage: "started", + source, + tombstone, + name: metadata.name, + treeHash: sourceSnapshot.treeHash, + managedPath, + managedExistedBefore, + createdAt: new Date(this.#now()).toISOString() + }; + await this.#writeJournal(journal); + try { + const stored = await this.#storeSource( + source, + "standalone-archive", + request.sourceUrl, + request.revision, + journal.treeHash + ); + if (stored.record.name !== journal.name || stored.record.treeHash !== journal.treeHash || !samePath(stored.managedPath, journal.managedPath)) { + throw new StashError( + "source-changed", + `Archive source changed while the managed copy was being committed: "${source}".`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "managed-committed"); + await rename2(source, tombstone); + await this.#advanceArchiveJournal(journal, "source-tombstoned"); + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== stored.record.treeHash) { + throw new StashError( + "source-changed", + `Archived source changed before removal; recovery will preserve or restore it safely.`, + 4 + ); + } + await this.#advanceArchiveJournal(journal, "archive-committed"); + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `The skill is outside host discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + if (!warning) { + try { + await unlink2(this.#journalPath(journal.operationId)); + } catch (error) { + warning = `Archive committed, but its recovery journal remains for later cleanup: ${String(error)}`; + } + } + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash, + ...warning ? { warning } : {} + }; + } catch (error) { + await this.#recoverArchiveJournal( + journal, + this.#journalPath(journal.operationId) + ); + throw error; + } + }); + } + async activate(request) { + return this.#withLock(async () => { + if (!NAME_PATTERN2.test(request.name)) { + throw new StashError("invalid-argument", "Invalid skill name.", 2); + } + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${request.name}" no longer matches its recorded hash.`, + 3 + ); + } + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const existing = await pathType(deploymentPath); + const tracked = record.deployments.find( + (deployment) => samePath(deployment.path, deploymentPath) && deployment.targetId === targetId + ); + if (existing !== "missing") { + if (existing === "directory" && tracked) { + const existingSnapshot = await snapshotTree(deploymentPath); + if (existingSnapshot.treeHash === record.treeHash) { + return { + status: "already-deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment: tracked, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } + } + throw new StashError( + "deployment-conflict", + `Target already exists and will not be overwritten: "${deploymentPath}".`, + 3 + ); + } + const stage = path6.join( + path6.dirname(target.root), + `.stash-deploy-${record.name}-${randomUUID()}` + ); + let committed = false; + try { + await copySnapshot(managedSnapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "copy-verification-failed", + `Deployment verification failed for "${record.name}".`, + 4 + ); + } + await rename2(stage, deploymentPath); + committed = true; + const deployment = { + deploymentId: randomUUID(), + skillId: record.skillId, + targetId, + host: target.host, + scope: target.scope, + root: target.root, + path: deploymentPath, + method: "copy", + ownership: "stash", + treeHash: record.treeHash, + deployedAt: new Date(this.#now()).toISOString() + }; + record.deployments = [ + ...record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ), + deployment + ]; + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + await rm(deploymentPath, { recursive: true, force: false }).catch( + () => void 0 + ); + throw error; + } + return { + status: "deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment, + reloadRequired: target.host !== "claude-code", + warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." + }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => void 0); + } + } + }); + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path6.join(this.#managedRoot, request.name); + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, false, true); + const deploymentPath = path6.join(target.root, request.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 + ); + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path6.join( + path6.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); + throw new StashError( + "deployment-drift", + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, + 3 + ); + } + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); + throw error; + } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: target.host !== "claude-code", + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + }); + } + async status(request = {}) { + const recordsRoot = path6.join(this.#metadataRoot(), "records"); + let names; + if (request.name) { + names = [request.name]; + } else { + try { + names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "ENOENT") { + names = []; + } else { + throw error; + } + } + } + const skills = []; + for (const name of names) { + const record = await this.#readRecord(name); + if (!record) { + continue; + } + const managedPath = path6.join(this.#managedRoot, name); + const managedType = await pathType(managedPath); + let storeState = managedType === "missing" ? "missing" : "stored"; + let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; + let actualTreeHash; + if (managedType === "directory") { + try { + actualTreeHash = (await snapshotTree(managedPath)).treeHash; + storeIntegrity = actualTreeHash === record.treeHash ? "verified" : "drifted"; + } catch { + storeIntegrity = "drifted"; + } + } + const deployments = []; + for (const deployment of record.deployments) { + const type = await pathType(deployment.path); + if (type === "missing") { + deployments.push({ + ...deployment, + state: "missing", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "absent", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + if (type !== "directory") { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "drifted", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + continue; + } + try { + const deployedHash = (await snapshotTree(deployment.path)).treeHash; + deployments.push({ + ...deployment, + state: deployedHash === deployment.treeHash ? "deployed" : "drifted", + integrity: deployedHash === deployment.treeHash ? "verified" : "drifted", + actualTreeHash: deployedHash, + hostObservation: { + override: "unknown", + discovery: "present", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } catch { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: deployment.host === "claude-code" ? "live" : "restart-required" + } + }); + } + } + skills.push({ + skillId: record.skillId, + name, + managedPath, + store: { + state: storeState, + integrity: storeIntegrity, + expectedTreeHash: record.treeHash, + ...actualTreeHash ? { actualTreeHash } : {} + }, + source: record.source, + deployments + }); + } + return { + status: skills.length > 0 ? "ok" : "not-found", + managedRoot: this.#managedRoot, + skills + }; + } +}; +async function createStashLifecycle(options = {}) { + const loaded = await loadConfiguration(options); + const managedRoot = path6.resolve( + loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() + ); + return new StashLifecycleImplementation( + managedRoot, + options.now ?? Date.now + ); +} + +// src/cli.ts +function parseArguments(argv) { + const [command, ...rest] = argv; + const positionals = []; + const flags2 = /* @__PURE__ */ new Map(); + for (let index = 0; index < rest.length; index += 1) { + const current = rest[index]; + if (!current) { + continue; + } + if (!current.startsWith("--")) { + positionals.push(current); + continue; + } + const [rawName, inlineValue] = current.slice(2).split("=", 2); + const name = rawName ?? ""; + let value = inlineValue; + if (value === void 0 && rest[index + 1] !== void 0 && !rest[index + 1]?.startsWith("--")) { + value = rest[index + 1]; + index += 1; + } + const values = flags2.get(name) ?? []; + values.push(value ?? "true"); + flags2.set(name, values); + } + return { ...command ? { command } : {}, positionals, flags: flags2 }; +} +function flag(args, name) { + return args.flags.get(name)?.at(-1); +} +function flags(args, name) { + const values = args.flags.get(name); + return values && values.length > 0 ? values : void 0; +} +function booleanFlag(args, name) { + return args.flags.has(name) && flag(args, name) !== "false"; +} +function numberFlag(args, name) { + const value = flag(args, name); + if (value === void 0) { + return void 0; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new StashError( + "invalid-argument", + `--${name} must be a number.`, + 2 + ); + } + return parsed; +} +function createOptions(args) { + const root = flag(args, "root"); + const catalogId = flag(args, "root-id") ?? "default"; + const configPath = flag(args, "config"); + const cacheDir = flag(args, "cache-dir"); + const managedRoot = flag(args, "managed-root"); + const catalogs = root ? [ + { + id: catalogId, + root, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"] + } + ] : void 0; + return { + ...configPath ? { configPath } : {}, + ...cacheDir ? { cacheDir } : {}, + ...managedRoot ? { managedRoot } : {}, + ...catalogs ? { catalogs } : {} + }; +} +function lifecycleTarget(args) { + const host = flag(args, "host"); + const supported = /* @__PURE__ */ new Set([ + "codex", + "claude-code", + "antigravity-ide", + "antigravity-cli" + ]); + if (!host || !supported.has(host)) { + throw new StashError( + "invalid-argument", + "--host must be codex, claude-code, antigravity-ide, or antigravity-cli.", + 2 + ); + } + const scope = flag(args, "scope"); + if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + throw new StashError( + "invalid-argument", + "--scope must be user, workspace, or custom.", + 2 + ); + } + const root = flag(args, "host-root"); + const workspace = flag(args, "workspace"); + return { + host, + ...scope ? { scope } : {}, + ...root ? { root } : {}, + ...workspace ? { workspace } : {} + }; +} +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)} +`); +} +function printResolve(result) { + if (result.status === "no-match") { + process.stdout.write( + result.totalPossible > 0 ? `No materially relevant skills. ${result.totalPossible} possible match(es) were excluded. +` : "No matching skills.\n" + ); + return; + } + if (result.status !== "ok" && result.status !== "ambiguous-exact") { + process.stdout.write(`Stash: ${result.status} +`); + return; + } + process.stdout.write( + `${result.totalRelevant} relevant skill(s)` + (result.totalPossible > 0 ? `, ${result.totalPossible} possible match(es)` : "") + "\n" + ); + let currentScope = ""; + for (const match of result.matches) { + const group = match.group ?? "(ungrouped)"; + const source = match.source?.displayName && match.source.id ? `${match.source.displayName} [${match.source.id}]` : match.source?.displayName ?? match.source?.id ?? match.source?.url; + const scope = source ? `${source} / ${match.catalogId} / ${group}` : `${match.catalogId} / ${group}`; + if (scope !== currentScope) { + currentScope = scope; + process.stdout.write(` +${scope} +`); + } + const tier = match.relevance ? ` [${match.relevance.tier}]` : ""; + process.stdout.write(`- ${match.name}${tier} \u2014 ${match.description} +`); + process.stdout.write(` ref: ${match.ref} +`); + } + if (result.page.nextCursor) { + process.stdout.write(` +next_cursor: ${result.page.nextCursor} +`); + } +} +function printLifecycle(result) { + process.stdout.write( + `${result.name}: ${result.status} (${result.treeHash}) +skill_id: ${result.skillId} +${result.managedPath} +` + ); + if (result.deployment) { + process.stdout.write(`deployment: ${result.deployment.path} +`); + } + if (result.reloadRequired) { + process.stdout.write("Reload or restart the host before relying on discovery changes.\n"); + } + if (result.warning) { + process.stdout.write(`warning: ${result.warning} +`); + } +} +function usage() { + return `Stash \u2014 on-demand search for local Agent Skills + +Usage: + stash exact [--source ] [--group ] [--catalog ] [--json] + stash search [--source ] [--group ] [--catalog ] [--cursor ] [--include-possible] [--json] + stash list [--source ] [--group ] [--catalog ] [--cursor ] [--json] stash read [--resource ] [--format content|path|json] stash index [--catalog ] [--json] stash doctor [--catalog ] [--json] + stash install [--source-url ] [--revision ] [--json] + stash archive --host [--scope user] [--json] + stash activate --host [--scope user] [--json] + stash deactivate --host [--scope user] [--json] + stash status [name] [--json] Configuration: --config Override STASH_CONFIG/platform config. --root Use one catalog without a config file. --root-id Catalog id used with --root (default: default). --cache-dir Override STASH_CACHE_DIR/platform cache. + --managed-root Override STASH_MANAGED_HOME/platform managed store. + +Lifecycle targeting: + --host codex, claude-code, antigravity-ide, or antigravity-cli. + --scope user or custom (default: user; workspace is rejected). + --host-root Explicit discovery root; implies a custom location. Result pagination never caps the total relevant result set. +Lifecycle commands manage only the Stash-owned store and explicitly selected +standalone skills. They never mutate external catalogs, plugins, or host settings. `; } async function main() { @@ -9403,6 +11123,98 @@ async function main() { } return; } + case "install": + case "import": + case "add": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + `${args.command} requires a local skill directory.`, + 2 + ); + } + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(source)) { + throw new StashError( + "remote-install-unsupported", + "Remote installation is not supported in this release. Stage the skill locally, then install that directory.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.install({ + source, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "archive": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + "archive requires a standalone skill directory or a name with --host.", + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.archive({ + source, + target, + ...sourceUrl ? { sourceUrl } : {}, + ...revision ? { revision } : {} + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "activate": + case "deactivate": { + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + `${args.command} requires a managed skill name.`, + 2 + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const result = args.command === "activate" ? await lifecycle.activate({ name, target }) : await lifecycle.deactivate({ name, target }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "status": { + const name = args.positionals.join(" ").trim(); + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.status(name ? { name } : {}); + if (json) { + printJson(result); + } else if (result.status === "not-found") { + process.stdout.write(`No managed skills at ${result.managedRoot}. +`); + } else { + for (const skill of result.skills) { + process.stdout.write( + `${skill.name}: store=${skill.store.state}/${skill.store.integrity}, deployments=${skill.deployments.length} +` + ); + for (const deployment of skill.deployments) { + process.stdout.write( + ` - ${deployment.host}/${deployment.scope}: ${deployment.state} (${deployment.path}) +` + ); + } + } + } + return; + } default: throw new StashError( "invalid-argument", diff --git a/src/cli.ts b/src/cli.ts index 79dacea..b912310 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,13 @@ #!/usr/bin/env node import { createStashCatalog } from "./stash-catalog.js"; +import { createStashLifecycle } from "./stash-lifecycle.js"; import { StashError, type CatalogRegistration, + type LifecycleHost, + type LifecycleHostTarget, + type LifecycleMutationResult, type ResolveResult, } from "./types.js"; @@ -78,6 +82,7 @@ function createOptions(args: ParsedArguments) { const catalogId = flag(args, "root-id") ?? "default"; const configPath = flag(args, "config"); const cacheDir = flag(args, "cache-dir"); + const managedRoot = flag(args, "managed-root"); const catalogs: CatalogRegistration[] | undefined = root ? [ { @@ -93,10 +98,49 @@ function createOptions(args: ParsedArguments) { return { ...(configPath ? { configPath } : {}), ...(cacheDir ? { cacheDir } : {}), + ...(managedRoot ? { managedRoot } : {}), ...(catalogs ? { catalogs } : {}), }; } +function lifecycleTarget(args: ParsedArguments): LifecycleHostTarget { + const host = flag(args, "host"); + const supported = new Set([ + "codex", + "claude-code", + "antigravity-ide", + "antigravity-cli", + ]); + if (!host || !supported.has(host as LifecycleHost)) { + throw new StashError( + "invalid-argument", + "--host must be codex, claude-code, antigravity-ide, or antigravity-cli.", + 2, + ); + } + const scope = flag(args, "scope"); + if ( + scope !== undefined && + scope !== "user" && + scope !== "workspace" && + scope !== "custom" + ) { + throw new StashError( + "invalid-argument", + "--scope must be user, workspace, or custom.", + 2, + ); + } + const root = flag(args, "host-root"); + const workspace = flag(args, "workspace"); + return { + host: host as LifecycleHost, + ...(scope ? { scope } : {}), + ...(root ? { root } : {}), + ...(workspace ? { workspace } : {}), + }; +} + function printJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } @@ -146,6 +190,21 @@ function printResolve(result: ResolveResult): void { } } +function printLifecycle(result: LifecycleMutationResult): void { + process.stdout.write( + `${result.name}: ${result.status} (${result.treeHash})\nskill_id: ${result.skillId}\n${result.managedPath}\n`, + ); + if (result.deployment) { + process.stdout.write(`deployment: ${result.deployment.path}\n`); + } + if (result.reloadRequired) { + process.stdout.write("Reload or restart the host before relying on discovery changes.\n"); + } + if (result.warning) { + process.stdout.write(`warning: ${result.warning}\n`); + } +} + function usage(): string { return `Stash — on-demand search for local Agent Skills @@ -156,14 +215,27 @@ Usage: stash read [--resource ] [--format content|path|json] stash index [--catalog ] [--json] stash doctor [--catalog ] [--json] + stash install [--source-url ] [--revision ] [--json] + stash archive --host [--scope user] [--json] + stash activate --host [--scope user] [--json] + stash deactivate --host [--scope user] [--json] + stash status [name] [--json] Configuration: --config Override STASH_CONFIG/platform config. --root Use one catalog without a config file. --root-id Catalog id used with --root (default: default). --cache-dir Override STASH_CACHE_DIR/platform cache. + --managed-root Override STASH_MANAGED_HOME/platform managed store. + +Lifecycle targeting: + --host codex, claude-code, antigravity-ide, or antigravity-cli. + --scope user or custom (default: user; workspace is rejected). + --host-root Explicit discovery root; implies a custom location. Result pagination never caps the total relevant result set. +Lifecycle commands manage only the Stash-owned store and explicitly selected +standalone skills. They never mutate external catalogs, plugins, or host settings. `; } @@ -318,6 +390,98 @@ async function main(): Promise { } return; } + case "install": + case "import": + case "add": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + `${args.command} requires a local skill directory.`, + 2, + ); + } + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(source)) { + throw new StashError( + "remote-install-unsupported", + "Remote installation is not supported in this release. Stage the skill locally, then install that directory.", + 2, + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.install({ + source, + ...(sourceUrl ? { sourceUrl } : {}), + ...(revision ? { revision } : {}), + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "archive": { + const source = args.positionals.join(" ").trim(); + if (!source) { + throw new StashError( + "invalid-argument", + "archive requires a standalone skill directory or a name with --host.", + 2, + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const sourceUrl = flag(args, "source-url"); + const revision = flag(args, "revision"); + const result = await lifecycle.archive({ + source, + target, + ...(sourceUrl ? { sourceUrl } : {}), + ...(revision ? { revision } : {}), + }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "activate": + case "deactivate": { + const name = args.positionals.join(" ").trim(); + if (!name) { + throw new StashError( + "invalid-argument", + `${args.command} requires a managed skill name.`, + 2, + ); + } + const lifecycle = await createStashLifecycle(createOptions(args)); + const target = lifecycleTarget(args); + const result = + args.command === "activate" + ? await lifecycle.activate({ name, target }) + : await lifecycle.deactivate({ name, target }); + json ? printJson(result) : printLifecycle(result); + return; + } + case "status": { + const name = args.positionals.join(" ").trim(); + const lifecycle = await createStashLifecycle(createOptions(args)); + const result = await lifecycle.status(name ? { name } : {}); + if (json) { + printJson(result); + } else if (result.status === "not-found") { + process.stdout.write(`No managed skills at ${result.managedRoot}.\n`); + } else { + for (const skill of result.skills) { + process.stdout.write( + `${skill.name}: store=${skill.store.state}/${skill.store.integrity}, deployments=${skill.deployments.length}\n`, + ); + for (const deployment of skill.deployments) { + process.stdout.write( + ` - ${deployment.host}/${deployment.scope}: ${deployment.state} (${deployment.path})\n`, + ); + } + } + } + return; + } default: throw new StashError( "invalid-argument", diff --git a/src/index.ts b/src/index.ts index b560846..5266d4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { createStashCatalog } from "./stash-catalog.js"; +export { createStashLifecycle } from "./stash-lifecycle.js"; export { INDEX_SCHEMA_VERSION, RESULT_SCHEMA_VERSION, @@ -12,10 +13,25 @@ export type { CreateStashCatalogOptions, DoctorRequest, DoctorResult, + LifecycleActivateRequest, + LifecycleArchiveRequest, + LifecycleDeactivateRequest, + LifecycleDeployment, + LifecycleHost, + LifecycleHostTarget, + LifecycleInstallRequest, + LifecycleMutationResult, + LifecycleScope, + LifecycleSkillStatus, + LifecycleSource, + LifecycleStatusRequest, + LifecycleStatusResult, + ManagedSkillRecord, ReadRequest, ReadResult, RefreshRequest, RefreshResult, + RelatedSkillCopy, ResolveFilters, ResolveRequest, ResolveResult, @@ -23,6 +39,7 @@ export type { SkillRecord, StashCatalog, StashConfiguration, + StashLifecycle, TrustState, Vendor, VendorCompatibility, diff --git a/src/internal/configuration.ts b/src/internal/configuration.ts index 4f41a73..9820a8b 100644 --- a/src/internal/configuration.ts +++ b/src/internal/configuration.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { parse } from "yaml"; import type { @@ -9,7 +9,11 @@ import type { TrustState, } from "../types.js"; import { StashError } from "../types.js"; -import { platformCachePath, platformConfigPath } from "./util.js"; +import { + platformCachePath, + platformConfigPath, + platformManagedPath, +} from "./util.js"; const DEFAULTS: StashDefaults = { pageSize: 40, @@ -120,16 +124,23 @@ export async function loadConfiguration( const cacheDir = path.resolve(options.cacheDir ?? platformCachePath()); if (options.catalogs) { - const catalogs = options.catalogs.map((catalog) => ({ + let catalogs = options.catalogs.map((catalog) => ({ ...catalog, root: path.resolve(catalog.root), })); + const managedRoot = options.managedRoot + ? path.resolve(options.managedRoot) + : undefined; + if (managedRoot) { + catalogs = await includeManagedCatalog(catalogs, managedRoot); + } validateUniqueCatalogIds(catalogs); return { configuration: { version: 1, catalogs, defaults: parseDefaults(undefined, options.defaults), + ...(managedRoot ? { managedRoot } : {}), }, cacheDir, }; @@ -148,23 +159,35 @@ export async function loadConfiguration( 5, ); } - if (!Array.isArray(parsed.catalogs)) { + if (parsed.catalogs !== undefined && !Array.isArray(parsed.catalogs)) { throw new StashError( "invalid-config", - `Config "${configPath}" must define a catalogs array.`, + `Config "${configPath}" catalogs must be an array.`, 2, ); } const baseDir = path.dirname(configPath); - const catalogs = parsed.catalogs.map((catalog, index) => + let catalogs = (Array.isArray(parsed.catalogs) ? parsed.catalogs : []).map((catalog, index) => parseCatalog(catalog, baseDir, index), ); + const configuredManagedRoot = + typeof parsed.managedRoot === "string" && parsed.managedRoot.trim() + ? path.resolve(baseDir, parsed.managedRoot) + : undefined; + const managedRoot = path.resolve( + options.managedRoot ?? + process.env.STASH_MANAGED_HOME ?? + configuredManagedRoot ?? + platformManagedPath(), + ); + catalogs = await includeManagedCatalog(catalogs, managedRoot); validateUniqueCatalogIds(catalogs); return { configuration: { version: 1, catalogs, defaults: parseDefaults(parsed.defaults, options.defaults), + managedRoot, }, cacheDir, configPath, @@ -186,30 +209,109 @@ export async function loadConfiguration( } } + const managedRoot = path.resolve( + options.managedRoot ?? + process.env.STASH_MANAGED_HOME ?? + platformManagedPath(), + ); + if (process.env.STASH_HOME) { + const catalogs = await includeManagedCatalog( + [ + { + id: "default", + root: path.resolve(process.env.STASH_HOME), + enabled: true, + trust: "unreviewed", + followSymlinks: false, + }, + ], + managedRoot, + ); return { configuration: { version: 1, - catalogs: [ - { - id: "default", - root: path.resolve(process.env.STASH_HOME), - enabled: true, - trust: "unreviewed", - followSymlinks: false, - }, - ], + catalogs, defaults: parseDefaults(undefined, options.defaults), + managedRoot, }, cacheDir, }; } - throw new StashError( - "config-not-found", - `Stash config was not found at "${configPath}". Set STASH_CONFIG, STASH_HOME, or pass --config.`, - 2, - ); + if (explicitConfig) { + throw new StashError( + "config-not-found", + `Stash config was not found at "${configPath}".`, + 2, + ); + } + + const catalogs = await includeManagedCatalog([], managedRoot); + return { + configuration: { + version: 1, + catalogs, + defaults: parseDefaults(undefined, options.defaults), + managedRoot, + }, + cacheDir, + }; +} + +async function includeManagedCatalog( + catalogs: CatalogRegistration[], + managedRoot: string, +): Promise { + if (catalogs.some((catalog) => catalog.id === "managed")) { + throw new StashError( + "invalid-config", + 'Catalog id "managed" is reserved for the Stash-managed store.', + 2, + ); + } + const normalizedManagedRoot = path.resolve(managedRoot); + if ( + catalogs.some((catalog) => { + const normalizedCatalogRoot = path.resolve(catalog.root); + return process.platform === "win32" + ? normalizedCatalogRoot.toLocaleLowerCase("und") === + normalizedManagedRoot.toLocaleLowerCase("und") + : normalizedCatalogRoot === normalizedManagedRoot; + }) + ) { + return catalogs; + } + try { + const info = await stat(managedRoot); + if (!info.isDirectory()) { + throw new StashError( + "invalid-config", + `Managed root is not a directory: "${managedRoot}".`, + 2, + ); + } + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + if (code === "ENOENT") { + return catalogs; + } + throw error; + } + return [ + ...catalogs, + { + id: "managed", + root: managedRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + compatibility: ["codex", "claude-code", "antigravity"], + }, + ]; } function validateUniqueCatalogIds(catalogs: CatalogRegistration[]): void { diff --git a/src/internal/managed-projection.ts b/src/internal/managed-projection.ts new file mode 100644 index 0000000..e8c5164 --- /dev/null +++ b/src/internal/managed-projection.ts @@ -0,0 +1,281 @@ +import { + lstat, + readFile, + readdir, + realpath, + stat, +} from "node:fs/promises"; +import { platform } from "node:os"; +import path from "node:path"; +import type { + CatalogIndex, + ManagedSkillRecord, + RelatedSkillCopy, + SkillRecord, +} from "../types.js"; +import { isPathInside, sha256 } from "./util.js"; + +const MAX_FILES = 10_000; +const MAX_TOTAL_BYTES = 256 * 1024 * 1024; + +interface TreeEntry { + kind: "directory" | "file"; + relativePath: string; + size?: number; + contentHash?: string; +} + +interface ProjectionTarget { + kind: RelatedSkillCopy["kind"]; + record: ManagedSkillRecord; + expectedTreeHash: string; + deployment?: ManagedSkillRecord["deployments"][number]; +} + +function pathIdentity(value: string): string { + const normalized = path.resolve(value).normalize("NFKC"); + return platform() === "win32" + ? normalized.toLocaleLowerCase("und") + : normalized; +} + +function validRecord(value: unknown): value is ManagedSkillRecord { + if (!value || typeof value !== "object") { + return false; + } + const record = value as ManagedSkillRecord; + return ( + record.schemaVersion === 1 && + typeof record.skillId === "string" && + typeof record.name === "string" && + typeof record.treeHash === "string" && + record.source !== null && + typeof record.source === "object" && + typeof record.source.location === "string" && + Array.isArray(record.deployments) + ); +} + +async function treeHash(rootInput: string): Promise { + try { + const rootInfo = await lstat(rootInput); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + return undefined; + } + const root = await realpath(rootInput); + const entries: TreeEntry[] = []; + let files = 0; + let totalBytes = 0; + + async function walk(directory: string, relativeDirectory: string) { + const children = await readdir(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + const relativePath = relativeDirectory + ? `${relativeDirectory}/${child.name}` + : child.name; + const childPath = path.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new Error("linked tree"); + } + if (before.isDirectory()) { + const canonical = await realpath(childPath); + if (!isPathInside(root, canonical)) { + throw new Error("tree escape"); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new Error("special file"); + } + files += 1; + totalBytes += before.size; + if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new Error("tree too large"); + } + const content = await readFile(childPath); + const after = await stat(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new Error("tree changed"); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content), + }); + } + } + + await walk(root, ""); + const fingerprint = entries + .map((entry) => + entry.kind === "directory" + ? `D\0${entry.relativePath}` + : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}`, + ) + .join("\n"); + return sha256(fingerprint); + } catch { + return undefined; + } +} + +function relatedCopy( + target: ProjectionTarget, + record: SkillRecord, +): RelatedSkillCopy { + if (target.kind === "deployment" && target.deployment) { + return { + kind: "deployment", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId, + targetId: target.deployment.targetId, + host: target.deployment.host, + scope: target.deployment.scope, + }; + } + return { + kind: "source", + catalogId: record.catalogId, + ref: record.ref, + skillId: target.record.skillId, + }; +} + +export async function projectManagedCopies( + sourceIndexes: CatalogIndex[], + managedRoot: string | undefined, +): Promise<{ indexes: CatalogIndex[]; fingerprintPart: string }> { + if (!managedRoot || !sourceIndexes.some((index) => index.catalogId === "managed")) { + return { indexes: sourceIndexes, fingerprintPart: "" }; + } + const indexes = sourceIndexes.map((index) => ({ + ...index, + records: index.records.map((record) => ({ ...record })), + warnings: [...index.warnings], + })); + const managedIndex = indexes.find((index) => index.catalogId === "managed"); + if (!managedIndex) { + return { indexes, fingerprintPart: "" }; + } + + let recordFiles: string[]; + const recordsRoot = path.join(managedRoot, ".stash", "records"); + try { + recordFiles = (await readdir(recordsRoot)) + .filter((name) => name.endsWith(".json")) + .sort((left, right) => left.localeCompare(right, "en")); + } catch { + return { indexes, fingerprintPart: "" }; + } + + const targets = new Map(); + const managedRecords = new Map(); + for (const file of recordFiles) { + try { + const parsed = JSON.parse( + await readFile(path.join(recordsRoot, file), "utf8"), + ) as unknown; + if (!validRecord(parsed)) { + throw new Error("invalid record"); + } + managedRecords.set(parsed.name, parsed); + targets.set(pathIdentity(parsed.source.location), { + kind: "source", + record: parsed, + expectedTreeHash: parsed.treeHash, + }); + for (const deployment of parsed.deployments) { + if ( + deployment.ownership === "stash" && + deployment.skillId === parsed.skillId + ) { + targets.set(pathIdentity(deployment.path), { + kind: "deployment", + record: parsed, + expectedTreeHash: deployment.treeHash, + deployment, + }); + } + } + } catch { + managedIndex.warnings.push({ + code: "invalid-lifecycle-record", + message: `Ignored invalid lifecycle projection record "${file}".`, + path: `.stash/records/${file}`, + }); + } + } + + const canonicalBySkillId = new Map(); + for (const record of managedIndex.records) { + const managedRecord = managedRecords.get(record.name); + if (managedRecord) { + record.managedSkillId = managedRecord.skillId; + record.relatedCopies = []; + canonicalBySkillId.set(managedRecord.skillId, record); + } + } + + const events: string[] = []; + for (const index of indexes) { + if (index.catalogId === "managed") { + continue; + } + const visible: SkillRecord[] = []; + for (const record of index.records) { + const skillRoot = path.dirname( + path.resolve(index.root, record.relativeSkillFile), + ); + const target = targets.get(pathIdentity(skillRoot)); + const canonical = target + ? canonicalBySkillId.get(target.record.skillId) + : undefined; + if (!target || !canonical) { + visible.push(record); + continue; + } + const actualTreeHash = await treeHash(skillRoot); + if (actualTreeHash !== target.expectedTreeHash) { + visible.push(record); + index.warnings.push({ + code: "managed-copy-drift", + message: `Managed-related ${target.kind} "${record.ref}" drifted and remains a separate result.`, + ref: record.ref, + path: record.relativeSkillFile, + }); + events.push(`drift:${record.ref}:${actualTreeHash ?? "unavailable"}`); + continue; + } + const related = relatedCopy(target, record); + canonical.relatedCopies = [...(canonical.relatedCopies ?? []), related]; + events.push( + `fold:${record.ref}:${target.record.skillId}:${target.kind}:${actualTreeHash}`, + ); + } + index.records = visible; + } + + for (const record of managedIndex.records) { + if (record.relatedCopies) { + record.relatedCopies.sort((left, right) => + `${left.kind}:${left.catalogId}:${left.ref}`.localeCompare( + `${right.kind}:${right.catalogId}:${right.ref}`, + "en", + ), + ); + } + } + return { + indexes, + fingerprintPart: sha256(events.sort().join("\n")), + }; +} diff --git a/src/internal/search.ts b/src/internal/search.ts index f3de170..3f91ab7 100644 --- a/src/internal/search.ts +++ b/src/internal/search.ts @@ -497,5 +497,11 @@ export function toListedSkill(record: SkillRecord): ResolvedSkill { ? { source: record.source } : {}), contentHash: record.contentHash, + ...(record.managedSkillId + ? { managedSkillId: record.managedSkillId } + : {}), + ...(record.relatedCopies && record.relatedCopies.length > 0 + ? { relatedCopies: record.relatedCopies } + : {}), }; } diff --git a/src/internal/util.ts b/src/internal/util.ts index f76f32c..2105985 100644 --- a/src/internal/util.ts +++ b/src/internal/util.ts @@ -186,6 +186,34 @@ export function platformCachePath(): string { ); } +export function platformManagedPath(): string { + if (process.env.STASH_MANAGED_HOME) { + return path.resolve(process.env.STASH_MANAGED_HOME); + } + const currentPlatform = platform(); + if (currentPlatform === "win32") { + return path.join( + process.env.LOCALAPPDATA ?? path.join(homedir(), "AppData", "Local"), + "stash", + "managed", + ); + } + if (currentPlatform === "darwin") { + return path.join( + homedir(), + "Library", + "Application Support", + "stash", + "managed", + ); + } + return path.join( + process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share"), + "stash", + "managed", + ); +} + export function safeCatalogSegment(value: string): string { return value.replace(/[^a-zA-Z0-9._-]/gu, "_"); } diff --git a/src/stash-catalog.ts b/src/stash-catalog.ts index 4b81a3f..3bfaf03 100644 --- a/src/stash-catalog.ts +++ b/src/stash-catalog.ts @@ -27,6 +27,7 @@ import { scanCatalog, writeFreshIndex, } from "./internal/catalog-index.js"; +import { projectManagedCopies } from "./internal/managed-projection.js"; import { ROUTING_PROFILE_VERSION, searchRecords, @@ -124,7 +125,10 @@ class StashCatalogImplementation implements StashCatalog { this.#now = now; } - async #loadIndexes(catalogIds?: string[]): Promise { + async #loadIndexes( + catalogIds?: string[], + applyManagedProjection = true, + ): Promise { const selected = this.#selectRegistrations(catalogIds); const indexes: CatalogIndex[] = []; for (const catalog of selected) { @@ -136,13 +140,20 @@ class StashCatalogImplementation implements StashCatalog { ); indexes.push(loaded.index); } + const projected = applyManagedProjection + ? await projectManagedCopies(indexes, this.#configuration.managedRoot) + : { indexes, fingerprintPart: "" }; return { - indexes, + indexes: projected.indexes, registrations: selected, - warnings: indexes.flatMap((index) => index.warnings), + warnings: projected.indexes.flatMap((index) => index.warnings), fingerprint: sha256( - indexes - .map((index) => `${index.catalogId}:${index.fingerprint}`) + [ + ...projected.indexes.map( + (index) => `${index.catalogId}:${index.fingerprint}`, + ), + projected.fingerprintPart, + ] .sort() .join("\n"), ), @@ -479,7 +490,7 @@ class StashCatalogImplementation implements StashCatalog { resource, }; } - const loaded = await this.#loadIndexes(); + const loaded = await this.#loadIndexes(undefined, false); let record: SkillRecord | undefined; let index: CatalogIndex | undefined; for (const candidateIndex of loaded.indexes) { diff --git a/src/stash-lifecycle.ts b/src/stash-lifecycle.ts new file mode 100644 index 0000000..f9016b1 --- /dev/null +++ b/src/stash-lifecycle.ts @@ -0,0 +1,1622 @@ +import { randomUUID } from "node:crypto"; +import { + lstat, + mkdir, + readFile, + readdir, + realpath, + rename, + rm, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { homedir, platform } from "node:os"; +import path from "node:path"; +import { parse } from "yaml"; +import type { + CreateStashCatalogOptions, + LifecycleActivateRequest, + LifecycleArchiveRequest, + LifecycleDeactivateRequest, + LifecycleDeployment, + LifecycleHost, + LifecycleHostTarget, + LifecycleInstallRequest, + LifecycleMutationResult, + LifecycleScope, + LifecycleSkillStatus, + LifecycleStatusRequest, + LifecycleStatusResult, + ManagedSkillRecord, + StashLifecycle, + VendorCompatibility, +} from "./types.js"; +import { StashError } from "./types.js"; +import { loadConfiguration } from "./internal/configuration.js"; +import { + isPathInside, + platformManagedPath, + sha256, +} from "./internal/util.js"; + +const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const MAX_FILES = 10_000; +const MAX_TOTAL_BYTES = 256 * 1024 * 1024; +const STORE_SCHEMA_VERSION = 1 as const; +const WINDOWS_RESERVED_NAMES = new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9", +]); + +interface TreeEntry { + kind: "directory" | "file"; + relativePath: string; + size?: number; + contentHash?: string; +} + +interface TreeSnapshot { + root: string; + treeHash: string; + entries: TreeEntry[]; + skillSource: string; + sidecarSource?: string; +} + +interface StoredSource { + record: ManagedSkillRecord; + managedPath: string; + created: boolean; +} + +interface ArchiveJournal { + schemaVersion: 1; + operationId: string; + stage: + | "started" + | "managed-committed" + | "source-tombstoned" + | "archive-committed"; + source: string; + tombstone: string; + name: string; + treeHash: string; + managedPath: string; + managedExistedBefore: boolean; + createdAt: string; +} + +interface LifecycleLockOwner { + schemaVersion: 1; + ownerToken: string; + pid: number; + createdAt: string; +} + +function asObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function compatibilityState(value: unknown): VendorCompatibility["codex"] { + return value === "supported" || + value === "partial" || + value === "unsupported" || + value === "unknown" + ? value + : "unknown"; +} + +function parseSkillMetadata(snapshot: TreeSnapshot): { + name: string; + compatibility: VendorCompatibility; +} { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec( + snapshot.skillSource.replace(/^\uFEFF/u, ""), + ); + if (!match?.[1]) { + throw new StashError( + "invalid-skill", + "SKILL.md must contain closed YAML frontmatter.", + 3, + ); + } + const metadata = asObject(parse(match[1])); + if ( + typeof metadata.name !== "string" || + metadata.name.length > 64 || + !NAME_PATTERN.test(metadata.name) + ) { + throw new StashError( + "invalid-skill", + "SKILL.md name must use 1-64 lowercase letters, digits, or hyphens.", + 3, + ); + } + if ( + typeof metadata.description !== "string" || + metadata.description.trim() === "" || + metadata.description.length > 1024 + ) { + throw new StashError( + "invalid-skill", + "SKILL.md description must be a non-empty string up to 1024 characters.", + 3, + ); + } + let compatibility: Record = {}; + if (snapshot.sidecarSource) { + const sidecar = asObject(parse(snapshot.sidecarSource)); + if (sidecar.schemaVersion !== undefined && sidecar.schemaVersion !== 1) { + throw new StashError( + "invalid-skill", + "stash.meta.yaml must use schemaVersion: 1.", + 3, + ); + } + compatibility = asObject(sidecar.compatibility); + } + return { + name: metadata.name, + compatibility: { + codex: compatibilityState(compatibility.codex), + "claude-code": compatibilityState(compatibility["claude-code"]), + antigravity: compatibilityState(compatibility.antigravity), + }, + }; +} + +function portablePathKey(relativePath: string): string { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); +} + +function validatePortableSegment(segment: string): void { + if (!segment || /[. ]$/u.test(segment)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot end in a dot or space: "${segment}".`, + 3, + ); + } + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new StashError( + "unsafe-skill-tree", + `Portable skill paths cannot use the reserved name "${segment}".`, + 3, + ); + } +} + +async function snapshotTree(sourceRoot: string): Promise { + const rootInput = path.resolve(sourceRoot); + const rootInfo = await lstat(rootInput).catch((error: unknown) => { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${rootInput}": ${String(error)}`, + 4, + ); + }); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new StashError( + "unsafe-skill-tree", + `Skill root must be a real directory, not a link: "${rootInput}".`, + 3, + ); + } + const root = await realpath(rootInput); + const entries: TreeEntry[] = []; + const pathKeys = new Set(); + let fileCount = 0; + let totalBytes = 0; + let skillSource: string | undefined; + let sidecarSource: string | undefined; + + async function walk(directory: string, relativeDirectory: string): Promise { + const children = await readdir(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory + ? `${relativeDirectory}/${child.name}` + : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new StashError( + "unsafe-skill-tree", + `Case-insensitive path collision at "${relativePath}".`, + 3, + ); + } + pathKeys.add(key); + const childPath = path.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new StashError( + "unsafe-skill-tree", + `Symlinks and junctions are not allowed: "${relativePath}".`, + 3, + ); + } + if (before.isDirectory()) { + const canonical = await realpath(childPath); + if (!isPathInside(root, canonical)) { + throw new StashError( + "unsafe-skill-tree", + `Directory escapes the skill root: "${relativePath}".`, + 3, + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new StashError( + "unsafe-skill-tree", + `Only regular files and directories are allowed: "${relativePath}".`, + 3, + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new StashError( + "skill-too-large", + `Skill exceeds ${MAX_FILES} files or ${MAX_TOTAL_BYTES} bytes.`, + 3, + ); + } + const content = await readFile(childPath); + const after = await stat(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new StashError( + "source-changed", + `Skill changed while it was being read: "${relativePath}".`, + 4, + ); + } + const contentHash = sha256(content); + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash, + }); + if (relativePath === "SKILL.md") { + skillSource = content.toString("utf8"); + } else if (relativePath === "stash.meta.yaml") { + sidecarSource = content.toString("utf8"); + } + } + } + + await walk(root, ""); + if (skillSource === undefined) { + throw new StashError( + "invalid-skill", + `Skill root must contain SKILL.md: "${root}".`, + 3, + ); + } + const fingerprint = entries + .map((entry) => + entry.kind === "directory" + ? `D\0${entry.relativePath}` + : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}`, + ) + .join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + skillSource, + ...(sidecarSource !== undefined ? { sidecarSource } : {}), + }; +} + +async function copySnapshot( + snapshot: TreeSnapshot, + destination: string, +): Promise { + await mkdir(destination, { recursive: false }); + for (const entry of snapshot.entries) { + const relative = entry.relativePath.split("/").join(path.sep); + const target = path.join(destination, relative); + if (entry.kind === "directory") { + await mkdir(target, { recursive: false }); + continue; + } + const source = path.join(snapshot.root, relative); + const content = await readFile(source); + if (sha256(content) !== entry.contentHash) { + throw new StashError( + "source-changed", + `Skill changed while it was being copied: "${entry.relativePath}".`, + 4, + ); + } + await writeFile(target, content, { flag: "wx" }); + } +} + +async function pathType(target: string): Promise<"missing" | "directory" | "link" | "other"> { + try { + const info = await lstat(target); + if (info.isSymbolicLink()) { + return "link"; + } + if (info.isDirectory()) { + return "directory"; + } + return "other"; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + if (code === "ENOENT") { + return "missing"; + } + throw error; + } +} + +function samePath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left).normalize("NFKC"); + const normalizedRight = path.resolve(right).normalize("NFKC"); + return platform() === "win32" + ? normalizedLeft.toLocaleLowerCase("und") === + normalizedRight.toLocaleLowerCase("und") + : normalizedLeft === normalizedRight; +} + +function pathIdentity(value: string): string { + const normalized = path.resolve(value).normalize("NFKC"); + return platform() === "win32" + ? normalized.toLocaleLowerCase("und") + : normalized; +} + +function targetIdentity(target: { + host: LifecycleHost; + scope: LifecycleScope; + root: string; +}): string { + return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; +} + +function resolveHostRoot(target: LifecycleHostTarget): { + host: LifecycleHost; + scope: LifecycleScope; + root: string; +} { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2, + ); + } + if (target.scope === "workspace") { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2, + ); + } + if (target.root) { + return { + host: target.host, + scope: target.scope ?? "custom", + root: path.resolve(target.root), + }; + } + const scope = target.scope ?? "user"; + if (scope === "custom") { + throw new StashError( + "invalid-argument", + "A custom lifecycle target requires root.", + 2, + ); + } + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path.join(homedir(), ".agents", "skills"), + }; + case "claude-code": + return { + host: target.host, + scope, + root: path.join(homedir(), ".claude", "skills"), + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path.join(homedir(), ".gemini", "config", "skills"), + }; + } +} + +async function isPluginContained(source: string): Promise { + let current = path.dirname(source); + for (let depth = 0; depth < 12; depth += 1) { + const markers = [ + path.join(current, ".claude-plugin", "plugin.json"), + path.join(current, ".codex-plugin", "plugin.json"), + path.join(current, "plugin.json"), + ]; + for (const marker of markers) { + if ((await pathType(marker)) !== "missing") { + return true; + } + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return false; +} + +class StashLifecycleImplementation implements StashLifecycle { + readonly #managedRoot: string; + readonly #now: () => number; + + constructor(managedRoot: string, now: () => number) { + this.#managedRoot = path.resolve(managedRoot); + this.#now = now; + } + + #metadataRoot(): string { + return path.join(this.#managedRoot, ".stash"); + } + + #recordPath(name: string): string { + return path.join(this.#metadataRoot(), "records", `${name}.json`); + } + + async #ensureLayout(): Promise { + await mkdir(path.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir(path.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir(path.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path.join(this.#managedRoot, "stash.catalog.yaml"); + try { + await writeFile( + manifestPath, + "schemaVersion: 1\nid: managed\ndefaults:\n trust: unreviewed\n compatibility:\n - codex\n - claude-code\n - antigravity\nskillDiscovery:\n ignore:\n - .stash\n", + { encoding: "utf8", flag: "wx" }, + ); + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + if (code !== "EEXIST") { + throw error; + } + } + } + + #journalPath(operationId: string): string { + return path.join(this.#metadataRoot(), "journal", `${operationId}.json`); + } + + async #writeJournal(journal: ArchiveJournal): Promise { + const finalPath = this.#journalPath(journal.operationId); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(journal, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + }); + try { + await rename(temporaryPath, finalPath); + } catch (error) { + await unlink(temporaryPath).catch(() => undefined); + throw error; + } + } + + async #advanceArchiveJournal( + journal: ArchiveJournal, + stage: ArchiveJournal["stage"], + ): Promise { + const next = { ...journal, stage }; + await this.#writeJournal(next); + journal.stage = stage; + } + + async #removeIncompleteManaged(journal: ArchiveJournal): Promise { + if (journal.managedExistedBefore) { + return; + } + const record = await this.#readRecord(journal.name); + if (record && record.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed record changed during archive recovery: "${journal.name}".`, + 4, + ); + } + if ((await pathType(journal.managedPath)) === "directory") { + const snapshot = await snapshotTree(journal.managedPath); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Managed tree changed during archive recovery: "${journal.managedPath}".`, + 4, + ); + } + await rm(journal.managedPath, { recursive: true, force: false }); + } + await unlink(this.#recordPath(journal.name)).catch((error: unknown) => { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + if (code !== "ENOENT") { + throw error; + } + }); + } + + #validateArchiveJournal(journal: ArchiveJournal, journalPath: string): void { + const stages = new Set([ + "started", + "managed-committed", + "source-tombstoned", + "archive-committed", + ]); + if ( + journal.schemaVersion !== 1 || + !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || + !stages.has(journal.stage) || + !NAME_PATTERN.test(journal.name) || + !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || + typeof journal.managedExistedBefore !== "boolean" || + typeof journal.createdAt !== "string" || + typeof journal.source !== "string" || + typeof journal.tombstone !== "string" || + typeof journal.managedPath !== "string" || + !path.isAbsolute(journal.source) || + !path.isAbsolute(journal.tombstone) || + !path.isAbsolute(journal.managedPath) + ) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5, + ); + } + const expectedManagedPath = path.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path.dirname(path.dirname(journal.source)); + if ( + !samePath(journal.managedPath, expectedManagedPath) || + !samePath(path.dirname(journal.tombstone), expectedTombstoneParent) || + !path.basename(journal.tombstone).startsWith( + `.stash-archive-${journal.name}-`, + ) || + isPathInside(this.#managedRoot, journal.source) || + isPathInside(this.#managedRoot, journal.tombstone) + ) { + throw new StashError( + "invalid-lifecycle-journal", + `Invalid or unsafe lifecycle journal "${journalPath}".`, + 5, + ); + } + } + + async #recoverArchiveJournal( + journal: ArchiveJournal, + journalPath: string, + ): Promise { + const sourceType = await pathType(journal.source); + const tombstoneType = await pathType(journal.tombstone); + if (journal.stage === "archive-committed") { + if (tombstoneType !== "missing") { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone is not a directory: "${journal.tombstone}".`, + 4, + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4, + ); + } + await rm(journal.tombstone, { recursive: true, force: false }); + } + await unlink(journalPath); + return; + } + + const sourceWasMoved = + journal.stage === "source-tombstoned" || + (sourceType === "missing" && tombstoneType !== "missing"); + if (sourceWasMoved) { + if (tombstoneType !== "directory") { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive source and tombstone cannot be reconciled for "${journal.name}".`, + 4, + ); + } + if (sourceType !== "missing") { + throw new StashError( + "archive-restore-conflict", + `Archive recovery preserved "${journal.tombstone}" because "${journal.source}" is occupied.`, + 4, + ); + } + const snapshot = await snapshotTree(journal.tombstone); + if (snapshot.treeHash !== journal.treeHash) { + throw new StashError( + "lifecycle-recovery-conflict", + `Archive tombstone drifted: "${journal.tombstone}".`, + 4, + ); + } + await rename(journal.tombstone, journal.source); + } + await this.#removeIncompleteManaged(journal); + await unlink(journalPath); + } + + async #recoverJournals(): Promise { + const journalRoot = path.join(this.#metadataRoot(), "journal"); + const files = (await readdir(journalRoot)) + .filter((name) => name.endsWith(".json")) + .sort((left, right) => left.localeCompare(right, "en")); + for (const file of files) { + const journalPath = path.join(journalRoot, file); + let journal: ArchiveJournal; + try { + journal = JSON.parse(await readFile(journalPath, "utf8")) as ArchiveJournal; + } catch (error) { + throw new StashError( + "invalid-lifecycle-journal", + `Unable to read lifecycle journal "${journalPath}": ${String(error)}`, + 5, + ); + } + this.#validateArchiveJournal(journal, journalPath); + await this.#recoverArchiveJournal(journal, journalPath); + } + } + + async #readLockOwner(lockPath: string): Promise { + let owner: LifecycleLockOwner; + try { + owner = JSON.parse( + await readFile(path.join(lockPath, "owner.json"), "utf8"), + ) as LifecycleLockOwner; + } catch (error) { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is missing or malformed at "${lockPath}"; refusing automatic recovery.`, + 4, + ); + } + if ( + owner.schemaVersion !== 1 || + !/^[0-9a-f-]{36}$/iu.test(owner.ownerToken) || + !Number.isInteger(owner.pid) || + owner.pid <= 0 || + typeof owner.createdAt !== "string" + ) { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock metadata is invalid at "${lockPath}"; refusing automatic recovery.`, + 4, + ); + } + return owner; + } + + #ownerIsAlive(owner: LifecycleLockOwner): boolean { + try { + process.kill(owner.pid, 0); + return true; + } catch { + return false; + } + } + + async #publishLock( + lockPath: string, + owner: LifecycleLockOwner, + ): Promise { + const temporaryPath = path.join( + this.#metadataRoot(), + `.lifecycle-lock-${owner.ownerToken}.tmp`, + ); + await mkdir(temporaryPath, { recursive: false }); + try { + await writeFile( + path.join(temporaryPath, "owner.json"), + `${JSON.stringify(owner)}\n`, + { encoding: "utf8", flag: "wx" }, + ); + try { + await rename(temporaryPath, lockPath); + return true; + } catch (error) { + if ((await pathType(lockPath)) === "directory") { + return false; + } + throw error; + } + } finally { + await rm(temporaryPath, { recursive: true, force: true }).catch( + () => undefined, + ); + } + } + + async #reclaimDeadLock(lockPath: string): Promise { + const guardPath = path.join(this.#metadataRoot(), "lifecycle.reclaim"); + try { + await mkdir(guardPath, { recursive: false }); + } catch (error) { + if ((await pathType(guardPath)) === "directory") { + throw new StashError( + "lifecycle-busy", + `Another process is checking stale lifecycle ownership at "${lockPath}". If no Stash process is running, follow the documented reclaim-guard repair procedure.`, + 4, + ); + } + throw error; + } + try { + if ((await pathType(lockPath)) === "missing") { + return; + } + if ((await pathType(lockPath)) !== "directory") { + throw new StashError( + "lifecycle-lock-corrupt", + `Lifecycle lock is not a directory at "${lockPath}".`, + 4, + ); + } + const owner = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(owner)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4, + ); + } + const reclaimedPath = path.join( + this.#metadataRoot(), + `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}`, + ); + await rename(lockPath, reclaimedPath); + const reclaimedOwner = await this.#readLockOwner(reclaimedPath); + if (reclaimedOwner.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-corrupt", + "Lifecycle lock ownership changed during stale recovery.", + 4, + ); + } + await rm(reclaimedPath, { recursive: true, force: false }); + } finally { + await rm(guardPath, { recursive: true, force: true }).catch( + () => undefined, + ); + } + } + + async #acquireLock(lockPath: string): Promise { + const owner: LifecycleLockOwner = { + schemaVersion: 1, + ownerToken: randomUUID(), + pid: process.pid, + createdAt: new Date(this.#now()).toISOString(), + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await this.#publishLock(lockPath, owner)) { + return owner; + } + const existing = await this.#readLockOwner(lockPath); + if (this.#ownerIsAlive(existing)) { + throw new StashError( + "lifecycle-busy", + `Another lifecycle operation holds "${lockPath}".`, + 4, + ); + } + await this.#reclaimDeadLock(lockPath); + } + throw new StashError( + "lifecycle-busy", + `Lifecycle ownership changed while acquiring "${lockPath}".`, + 4, + ); + } + + async #releaseLock( + lockPath: string, + owner: LifecycleLockOwner, + ): Promise { + const current = await this.#readLockOwner(lockPath); + if (current.ownerToken !== owner.ownerToken) { + throw new StashError( + "lifecycle-lock-lost", + `Lifecycle lock ownership changed at "${lockPath}".`, + 4, + ); + } + await rm(lockPath, { recursive: true, force: false }); + } + + async #withLock(operation: () => Promise): Promise { + await this.#ensureLayout(); + const lockPath = path.join(this.#metadataRoot(), "lifecycle.lock"); + const owner = await this.#acquireLock(lockPath); + try { + await this.#recoverJournals(); + return await operation(); + } finally { + await this.#releaseLock(lockPath, owner); + } + } + + async #readRecord(name: string): Promise { + const recordPath = this.#recordPath(name); + try { + const parsed = JSON.parse(await readFile(recordPath, "utf8")) as ManagedSkillRecord; + if ( + parsed.schemaVersion !== STORE_SCHEMA_VERSION || + typeof parsed.skillId !== "string" || + parsed.skillId.length === 0 || + parsed.name !== name || + typeof parsed.treeHash !== "string" || + !Array.isArray(parsed.deployments) || + parsed.deployments.some( + (deployment) => + typeof deployment.deploymentId !== "string" || + deployment.skillId !== parsed.skillId || + typeof deployment.targetId !== "string" || + deployment.targetId !== + targetIdentity(deployment) || + deployment.ownership !== "stash" || + !samePath(deployment.path, path.join(deployment.root, parsed.name)), + ) + ) { + throw new Error("invalid lifecycle record shape"); + } + return parsed; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + if (code === "ENOENT") { + return undefined; + } + throw new StashError( + "invalid-lifecycle-record", + `Unable to read lifecycle record "${recordPath}": ${String(error)}`, + 5, + ); + } + } + + async #writeRecord(record: ManagedSkillRecord): Promise { + const finalPath = this.#recordPath(record.name); + const temporaryPath = `${finalPath}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(record, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + }); + try { + await rename(temporaryPath, finalPath); + } catch (error) { + await unlink(temporaryPath).catch(() => undefined); + throw error; + } + } + + async #assertSourceBoundary(source: string): Promise { + const canonicalSource = await realpath(source); + const canonicalManaged = await realpath(this.#managedRoot); + if ( + isPathInside(canonicalManaged, canonicalSource) || + isPathInside(canonicalSource, canonicalManaged) + ) { + throw new StashError( + "unsafe-source", + "A lifecycle source cannot be inside, contain, or equal the managed root.", + 3, + ); + } + } + + async #canonicalHostRoot( + root: string, + create: boolean, + allowMissing = false, + ): Promise { + let type = await pathType(root); + if (type === "missing" && create) { + await mkdir(root, { recursive: true }); + type = await pathType(root); + } + if (type === "missing" && allowMissing) { + return path.resolve(root); + } + if (type !== "directory") { + throw new StashError( + "unsafe-host-root", + `Host root must be a real directory, not a link or special path: "${root}".`, + 3, + ); + } + return realpath(root); + } + + async #storeSource( + source: string, + kind: ManagedSkillRecord["source"]["kind"], + sourceUrl?: string, + revision?: string, + expectedTreeHash?: string, + ): Promise { + await this.#assertSourceBoundary(source); + const snapshot = await snapshotTree(source); + if (expectedTreeHash && snapshot.treeHash !== expectedTreeHash) { + throw new StashError( + "source-changed", + `Skill changed before its managed copy could be committed: "${source}".`, + 4, + ); + } + const metadata = parseSkillMetadata(snapshot); + const managedPath = path.join(this.#managedRoot, metadata.name); + const existingType = await pathType(managedPath); + if (existingType !== "missing") { + if (existingType !== "directory") { + throw new StashError( + "managed-conflict", + `Managed target is not a directory: "${managedPath}".`, + 3, + ); + } + const existingRecord = await this.#readRecord(metadata.name); + const existingSnapshot = await snapshotTree(managedPath); + if ( + existingRecord && + existingRecord.treeHash === snapshot.treeHash && + existingSnapshot.treeHash === snapshot.treeHash + ) { + return { + record: existingRecord, + managedPath, + created: false, + }; + } + throw new StashError( + "managed-conflict", + `Managed skill "${metadata.name}" already exists with different content or metadata.`, + 3, + ); + } + if (await this.#readRecord(metadata.name)) { + throw new StashError( + "managed-conflict", + `Lifecycle metadata exists without its managed skill: "${metadata.name}".`, + 3, + ); + } + + const stage = path.join(this.#metadataRoot(), "staging", randomUUID()); + let committed = false; + try { + await copySnapshot(snapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== snapshot.treeHash) { + throw new StashError( + "copy-verification-failed", + `Staged tree hash differs for "${metadata.name}".`, + 4, + ); + } + const timestamp = new Date(this.#now()).toISOString(); + const record: ManagedSkillRecord = { + schemaVersion: STORE_SCHEMA_VERSION, + skillId: randomUUID(), + name: metadata.name, + treeHash: snapshot.treeHash, + source: { + kind, + location: snapshot.root, + importedAt: timestamp, + ...(sourceUrl ? { url: sourceUrl } : {}), + ...(revision ? { revision } : {}), + }, + compatibility: metadata.compatibility, + deployments: [], + lastValidatedAt: timestamp, + }; + await rename(stage, managedPath); + committed = true; + try { + await this.#writeRecord(record); + } catch (error) { + await rm(managedPath, { recursive: true, force: false }).catch( + () => undefined, + ); + throw error; + } + return { record, managedPath, created: true }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => undefined); + } + } + } + + async install( + request: LifecycleInstallRequest, + ): Promise { + return this.#withLock(async () => { + const stored = await this.#storeSource( + path.resolve(request.source), + "local-import", + request.sourceUrl, + request.revision, + ); + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash, + }; + }); + } + + async archive( + request: LifecycleArchiveRequest, + ): Promise { + return this.#withLock(async () => { + const resolvedTarget = resolveHostRoot(request.target); + resolvedTarget.root = await this.#canonicalHostRoot( + resolvedTarget.root, + false, + ); + const looksLikeName = NAME_PATTERN.test(request.source); + const source = path.resolve( + looksLikeName + ? path.join(resolvedTarget.root, request.source) + : request.source, + ); + if (!samePath(path.dirname(source), resolvedTarget.root)) { + throw new StashError( + "unsafe-archive-source", + `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, + 3, + ); + } + if (await isPluginContained(source)) { + throw new StashError( + "plugin-lifecycle-delegated", + "Plugin-contained skills must be managed by the host plugin manager.", + 3, + ); + } + await this.#assertSourceBoundary(source); + const sourceSnapshot = await snapshotTree(source); + const metadata = parseSkillMetadata(sourceSnapshot); + const managedPath = path.join(this.#managedRoot, metadata.name); + const managedExistedBefore = + (await pathType(managedPath)) !== "missing" || + (await this.#readRecord(metadata.name)) !== undefined; + const tombstoneParent = path.dirname(resolvedTarget.root); + await mkdir(tombstoneParent, { recursive: true }); + const tombstone = path.join( + tombstoneParent, + `.stash-archive-${metadata.name}-${randomUUID()}`, + ); + const journal: ArchiveJournal = { + schemaVersion: 1, + operationId: randomUUID(), + stage: "started", + source, + tombstone, + name: metadata.name, + treeHash: sourceSnapshot.treeHash, + managedPath, + managedExistedBefore, + createdAt: new Date(this.#now()).toISOString(), + }; + await this.#writeJournal(journal); + try { + const stored = await this.#storeSource( + source, + "standalone-archive", + request.sourceUrl, + request.revision, + journal.treeHash, + ); + if ( + stored.record.name !== journal.name || + stored.record.treeHash !== journal.treeHash || + !samePath(stored.managedPath, journal.managedPath) + ) { + throw new StashError( + "source-changed", + `Archive source changed while the managed copy was being committed: "${source}".`, + 4, + ); + } + await this.#advanceArchiveJournal(journal, "managed-committed"); + await rename(source, tombstone); + await this.#advanceArchiveJournal(journal, "source-tombstoned"); + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== stored.record.treeHash) { + throw new StashError( + "source-changed", + `Archived source changed before removal; recovery will preserve or restore it safely.`, + 4, + ); + } + await this.#advanceArchiveJournal(journal, "archive-committed"); + let warning: string | undefined; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `The skill is outside host discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + if (!warning) { + try { + await unlink(this.#journalPath(journal.operationId)); + } catch (error) { + warning = `Archive committed, but its recovery journal remains for later cleanup: ${String(error)}`; + } + } + return { + status: stored.created ? "stored" : "already-stored", + name: stored.record.name, + skillId: stored.record.skillId, + managedPath: stored.managedPath, + treeHash: stored.record.treeHash, + ...(warning ? { warning } : {}), + }; + } catch (error) { + await this.#recoverArchiveJournal( + journal, + this.#journalPath(journal.operationId), + ); + throw error; + } + }); + } + + async activate( + request: LifecycleActivateRequest, + ): Promise { + return this.#withLock(async () => { + if (!NAME_PATTERN.test(request.name)) { + throw new StashError("invalid-argument", "Invalid skill name.", 2); + } + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4, + ); + } + const managedPath = path.join(this.#managedRoot, request.name); + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill "${request.name}" no longer matches its recorded hash.`, + 3, + ); + } + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, true); + const deploymentPath = path.join(target.root, request.name); + const targetId = targetIdentity(target); + const existing = await pathType(deploymentPath); + const tracked = record.deployments.find( + (deployment) => + samePath(deployment.path, deploymentPath) && + deployment.targetId === targetId, + ); + if (existing !== "missing") { + if (existing === "directory" && tracked) { + const existingSnapshot = await snapshotTree(deploymentPath); + if (existingSnapshot.treeHash === record.treeHash) { + return { + status: "already-deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment: tracked, + reloadRequired: target.host !== "claude-code", + warning: + "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed.", + }; + } + } + throw new StashError( + "deployment-conflict", + `Target already exists and will not be overwritten: "${deploymentPath}".`, + 3, + ); + } + + const stage = path.join( + path.dirname(target.root), + `.stash-deploy-${record.name}-${randomUUID()}`, + ); + let committed = false; + try { + await copySnapshot(managedSnapshot, stage); + const stagedSnapshot = await snapshotTree(stage); + if (stagedSnapshot.treeHash !== record.treeHash) { + throw new StashError( + "copy-verification-failed", + `Deployment verification failed for "${record.name}".`, + 4, + ); + } + await rename(stage, deploymentPath); + committed = true; + const deployment: LifecycleDeployment = { + deploymentId: randomUUID(), + skillId: record.skillId, + targetId, + host: target.host, + scope: target.scope, + root: target.root, + path: deploymentPath, + method: "copy", + ownership: "stash", + treeHash: record.treeHash, + deployedAt: new Date(this.#now()).toISOString(), + }; + record.deployments = [ + ...record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath), + ), + deployment, + ]; + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + await rm(deploymentPath, { recursive: true, force: false }).catch( + () => undefined, + ); + throw error; + } + return { + status: "deployed", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + deployment, + reloadRequired: target.host !== "claude-code", + warning: + "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed.", + }; + } finally { + if (!committed) { + await rm(stage, { recursive: true, force: true }).catch(() => undefined); + } + } + }); + } + + async deactivate( + request: LifecycleDeactivateRequest, + ): Promise { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4, + ); + } + const managedPath = path.join(this.#managedRoot, request.name); + const target = resolveHostRoot(request.target); + target.root = await this.#canonicalHostRoot(target.root, false, true); + const deploymentPath = path.join(target.root, request.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => + samePath(candidate.path, deploymentPath) && + candidate.targetId === targetId, + ); + if ( + !deployment || + deployment.ownership !== "stash" || + deployment.skillId !== record.skillId + ) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3, + ); + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath), + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing.", + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3, + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3, + ); + } + const tombstone = path.join( + path.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}`, + ); + await rename(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename(tombstone, deploymentPath); + throw new StashError( + "deployment-drift", + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, + 3, + ); + } + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath), + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + record.deployments = previousDeployments; + await rename(tombstone, deploymentPath).catch(() => undefined); + throw error; + } + let warning: string | undefined; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: target.host !== "claude-code", + ...(warning ? { warning } : {}), + }; + } catch (error) { + if ((await pathType(tombstone)) !== "missing") { + await rename(tombstone, deploymentPath).catch(() => undefined); + } + throw error; + } + }); + } + + async status( + request: LifecycleStatusRequest = {}, + ): Promise { + const recordsRoot = path.join(this.#metadataRoot(), "records"); + let names: string[]; + if (request.name) { + names = [request.name]; + } else { + try { + names = (await readdir(recordsRoot)) + .filter((name) => name.endsWith(".json")) + .map((name) => name.slice(0, -".json".length)) + .sort((left, right) => left.localeCompare(right, "en")); + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + if (code === "ENOENT") { + names = []; + } else { + throw error; + } + } + } + const skills: LifecycleSkillStatus[] = []; + for (const name of names) { + const record = await this.#readRecord(name); + if (!record) { + continue; + } + const managedPath = path.join(this.#managedRoot, name); + const managedType = await pathType(managedPath); + let storeState: LifecycleSkillStatus["store"]["state"] = + managedType === "missing" ? "missing" : "stored"; + let storeIntegrity: LifecycleSkillStatus["store"]["integrity"] = + managedType === "directory" ? "unknown" : + managedType === "missing" ? "unknown" : "drifted"; + let actualTreeHash: string | undefined; + if (managedType === "directory") { + try { + actualTreeHash = (await snapshotTree(managedPath)).treeHash; + storeIntegrity = + actualTreeHash === record.treeHash ? "verified" : "drifted"; + } catch { + storeIntegrity = "drifted"; + } + } + const deployments: LifecycleSkillStatus["deployments"] = []; + for (const deployment of record.deployments) { + const type = await pathType(deployment.path); + if (type === "missing") { + deployments.push({ + ...deployment, + state: "missing", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "absent", + refresh: + deployment.host === "claude-code" + ? "live" + : "restart-required", + }, + }); + continue; + } + if (type !== "directory") { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "drifted", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: + deployment.host === "claude-code" + ? "live" + : "restart-required", + }, + }); + continue; + } + try { + const deployedHash = (await snapshotTree(deployment.path)).treeHash; + deployments.push({ + ...deployment, + state: + deployedHash === deployment.treeHash ? "deployed" : "drifted", + integrity: + deployedHash === deployment.treeHash ? "verified" : "drifted", + actualTreeHash: deployedHash, + hostObservation: { + override: "unknown", + discovery: "present", + refresh: + deployment.host === "claude-code" + ? "live" + : "restart-required", + }, + }); + } catch { + deployments.push({ + ...deployment, + state: "drifted", + integrity: "unknown", + hostObservation: { + override: "unknown", + discovery: "unknown", + refresh: + deployment.host === "claude-code" + ? "live" + : "restart-required", + }, + }); + } + } + skills.push({ + skillId: record.skillId, + name, + managedPath, + store: { + state: storeState, + integrity: storeIntegrity, + expectedTreeHash: record.treeHash, + ...(actualTreeHash ? { actualTreeHash } : {}), + }, + source: record.source, + deployments, + }); + } + return { + status: skills.length > 0 ? "ok" : "not-found", + managedRoot: this.#managedRoot, + skills, + }; + } +} + +export async function createStashLifecycle( + options: CreateStashCatalogOptions = {}, +): Promise { + const loaded = await loadConfiguration(options); + const managedRoot = path.resolve( + loaded.configuration.managedRoot ?? + options.managedRoot ?? + platformManagedPath(), + ); + return new StashLifecycleImplementation( + managedRoot, + options.now ?? Date.now, + ); +} diff --git a/src/types.ts b/src/types.ts index 1595899..ce1354f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,12 @@ export const RESULT_SCHEMA_VERSION = 1 as const; export const INDEX_SCHEMA_VERSION = 2 as const; export type Vendor = "codex" | "claude-code" | "antigravity"; +export type LifecycleHost = + | "codex" + | "claude-code" + | "antigravity-ide" + | "antigravity-cli"; +export type LifecycleScope = "user" | "workspace" | "custom"; export type CompatibilityState = "supported" | "partial" | "unsupported" | "unknown"; export type TrustState = "trusted" | "reviewed" | "unreviewed" | "quarantined"; export type RelevanceTier = "exact" | "strong" | "material" | "possible"; @@ -25,6 +31,16 @@ export interface CatalogRisk { capabilities: string[]; } +export interface RelatedSkillCopy { + kind: "source" | "deployment"; + catalogId: string; + ref: string; + skillId: string; + targetId?: string; + host?: LifecycleHost; + scope?: LifecycleScope; +} + export interface SkillRecord { ref: string; catalogId: string; @@ -44,6 +60,8 @@ export interface SkillRecord { contentHash: string; modifiedMs: number; size: number; + managedSkillId?: string; + relatedCopies?: RelatedSkillCopy[]; } export interface CatalogWarning { @@ -85,11 +103,13 @@ export interface StashConfiguration { version: 1; catalogs: CatalogRegistration[]; defaults: StashDefaults; + managedRoot?: string; } export interface CreateStashCatalogOptions { configPath?: string; cacheDir?: string; + managedRoot?: string; catalogs?: CatalogRegistration[]; defaults?: Partial; now?: () => number; @@ -142,6 +162,8 @@ export interface ResolvedSkill { trust: TrustState; source?: CatalogSource; contentHash: string; + managedSkillId?: string; + relatedCopies?: RelatedSkillCopy[]; relevance?: { tier: RelevanceTier; score: number; @@ -238,6 +260,130 @@ export interface StashCatalog { doctor(request?: DoctorRequest): Promise; } +export interface LifecycleSource { + kind: "local-import" | "standalone-archive"; + location: string; + importedAt: string; + url?: string; + revision?: string; +} + +export interface LifecycleDeployment { + deploymentId: string; + skillId: string; + targetId: string; + host: LifecycleHost; + scope: LifecycleScope; + root: string; + path: string; + method: "copy"; + ownership: "stash"; + treeHash: string; + deployedAt: string; +} + +export interface ManagedSkillRecord { + schemaVersion: 1; + skillId: string; + name: string; + treeHash: string; + source: LifecycleSource; + compatibility: VendorCompatibility; + deployments: LifecycleDeployment[]; + lastValidatedAt: string; +} + +export interface LifecycleHostTarget { + host: LifecycleHost; + scope?: LifecycleScope; + root?: string; + workspace?: string; +} + +export interface LifecycleInstallRequest { + source: string; + sourceUrl?: string; + revision?: string; +} + +export interface LifecycleArchiveRequest { + source: string; + target: LifecycleHostTarget; + sourceUrl?: string; + revision?: string; +} + +export interface LifecycleActivateRequest { + name: string; + target: LifecycleHostTarget; +} + +export interface LifecycleDeactivateRequest { + name: string; + target: LifecycleHostTarget; +} + +export interface LifecycleStatusRequest { + name?: string; +} + +export interface LifecycleMutationResult { + status: + | "stored" + | "deployed" + | "deactivated" + | "already-stored" + | "already-deployed"; + name: string; + skillId: string; + managedPath: string; + treeHash: string; + deployment?: LifecycleDeployment; + reloadRequired?: boolean; + warning?: string; +} + +export interface LifecycleSkillStatus { + skillId: string; + name: string; + managedPath: string; + store: { + state: "stored" | "missing"; + integrity: "verified" | "drifted" | "unknown"; + expectedTreeHash: string; + actualTreeHash?: string; + }; + source: LifecycleSource; + deployments: Array< + LifecycleDeployment & { + state: "deployed" | "missing" | "drifted"; + integrity: "verified" | "drifted" | "unknown"; + actualTreeHash?: string; + hostObservation: { + override: "unknown"; + discovery: "present" | "absent" | "unknown"; + refresh: "live" | "restart-required" | "unknown"; + }; + } + >; +} + +export interface LifecycleStatusResult { + status: "ok" | "not-found"; + managedRoot: string; + skills: LifecycleSkillStatus[]; +} + +export interface StashLifecycle { + install(request: LifecycleInstallRequest): Promise; + archive(request: LifecycleArchiveRequest): Promise; + activate(request: LifecycleActivateRequest): Promise; + deactivate( + request: LifecycleDeactivateRequest, + ): Promise; + status(request?: LifecycleStatusRequest): Promise; +} + export class StashError extends Error { readonly exitCode: number; readonly code: string; diff --git a/tests-dist/cli.test.mjs b/tests-dist/cli.test.mjs index c459ef5..9d6f72e 100644 --- a/tests-dist/cli.test.mjs +++ b/tests-dist/cli.test.mjs @@ -62,6 +62,82 @@ source: assert.equal(result.matches[0].source.id, "mengto"); }); +test("bundled skill CLI installs, resolves, deploys, and deactivates a managed skill", async () => { + const temp = await mkdtemp(path.join(tmpdir(), "stash-lifecycle-dist-test-")); + const source = path.join(temp, "source", "rare-skill"); + const managedRoot = path.join(temp, "managed"); + const hostRoot = path.join(temp, "host", "skills"); + await mkdir(source, { recursive: true }); + await writeFile( + path.join(source, "SKILL.md"), + "---\nname: rare-skill\ndescription: A bundled lifecycle fixture.\n---\n\n# Rare\n", + "utf8", + ); + const common = ["--managed-root", managedRoot, "--json"]; + const installed = JSON.parse( + ( + await execFileAsync(process.execPath, [ + bundledCli, + "install", + source, + ...common, + ]) + ).stdout, + ); + assert.equal(installed.status, "stored"); + + const resolved = JSON.parse( + ( + await execFileAsync(process.execPath, [ + bundledCli, + "exact", + "rare-skill", + ...common, + ]) + ).stdout, + ); + assert.equal(resolved.status, "ok"); + assert.equal(resolved.matches[0].catalogId, "managed"); + + const deployed = JSON.parse( + ( + await execFileAsync(process.execPath, [ + bundledCli, + "activate", + "rare-skill", + "--host", + "codex", + "--scope", + "custom", + "--host-root", + hostRoot, + ...common, + ]) + ).stdout, + ); + assert.equal(deployed.status, "deployed"); + await access(path.join(hostRoot, "rare-skill", "SKILL.md")); + + const deactivated = JSON.parse( + ( + await execFileAsync(process.execPath, [ + bundledCli, + "deactivate", + "rare-skill", + "--host", + "codex", + "--scope", + "custom", + "--host-root", + hostRoot, + ...common, + ]) + ).stdout, + ); + assert.equal(deactivated.status, "deactivated"); + await assert.rejects(access(path.join(hostRoot, "rare-skill"))); +}); + test("human output preserves URL-only source attribution", async () => { const temp = await mkdtemp(path.join(tmpdir(), "stash-url-source-test-")); const catalog = path.join(temp, "catalog"); @@ -162,9 +238,11 @@ test("npm package entrypoints match the compiled layout", async () => { const cli = path.join(root, "dist", "cli.js"); const library = await import(pathToFileURL(entrypoint).href); assert.equal(typeof library.createStashCatalog, "function"); + assert.equal(typeof library.createStashLifecycle, "function"); const { stdout } = await execFileAsync(process.execPath, [cli, "help"]); assert.match(stdout, /stash exact /u); + assert.match(stdout, /stash install /u); assert.match(stdout, /--source /u); }); diff --git a/tests/stash-lifecycle.test.ts b/tests/stash-lifecycle.test.ts new file mode 100644 index 0000000..6f3c31e --- /dev/null +++ b/tests/stash-lifecycle.test.ts @@ -0,0 +1,468 @@ +import assert from "node:assert/strict"; +import { + access, + mkdtemp, + mkdir, + readdir, + readFile, + rename, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createStashCatalog } from "../src/stash-catalog.js"; +import { createStashLifecycle } from "../src/stash-lifecycle.js"; +import { StashError } from "../src/types.js"; + +async function createStandaloneSkill( + parent: string, + name: string, +): Promise { + const root = path.join(parent, name); + await mkdir(path.join(root, "references"), { recursive: true }); + await writeFile( + path.join(root, "SKILL.md"), + `---\nname: ${name}\ndescription: A managed lifecycle fixture.\n---\n\n# ${name}\n`, + "utf8", + ); + await writeFile( + path.join(root, "references", "guide.md"), + "fixture guide\n", + "utf8", + ); + return root; +} + +async function lifecycleFixture(): Promise<{ + base: string; + managedRoot: string; + sourceRoot: string; +}> { + const base = await mkdtemp(path.join(tmpdir(), "stash-lifecycle-test-")); + const managedRoot = path.join(base, "managed"); + const sourceRoot = await createStandaloneSkill( + path.join(base, "sources"), + "rare-skill", + ); + return { base, managedRoot, sourceRoot }; +} + +test("install creates a searchable inactive canonical copy without changing source", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + assert.equal(installed.status, "stored"); + assert.match(installed.skillId, /^[0-9a-f-]{36}$/u); + await access(path.join(fixture.sourceRoot, "SKILL.md")); + assert.equal( + await readFile(path.join(installed.managedPath, "references", "guide.md"), "utf8"), + "fixture guide\n", + ); + + const catalog = await createStashCatalog({ + catalogs: [], + managedRoot: fixture.managedRoot, + cacheDir: path.join(fixture.base, "cache"), + }); + const resolved = await catalog.resolve({ kind: "exact", name: "rare-skill" }); + assert.equal(resolved.status, "ok"); + assert.equal(resolved.matches[0]?.catalogId, "managed"); + + const repeated = await lifecycle.install({ source: fixture.sourceRoot }); + assert.equal(repeated.status, "already-stored"); +}); + +test("activate and deactivate only mutate a tracked verified deployment", async () => { + const fixture = await lifecycleFixture(); + const hostRoot = path.join(fixture.base, "host-skills"); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + await lifecycle.install({ source: fixture.sourceRoot }); + const target = { + host: "codex" as const, + scope: "custom" as const, + root: hostRoot, + }; + const activated = await lifecycle.activate({ name: "rare-skill", target }); + assert.equal(activated.status, "deployed"); + assert.match(activated.warning ?? "", /DEPLOYED means present/u); + const deployedGuide = path.join(hostRoot, "rare-skill", "references", "guide.md"); + await writeFile(deployedGuide, "drifted\n", "utf8"); + await assert.rejects( + lifecycle.deactivate({ name: "rare-skill", target }), + (error: unknown) => + error instanceof StashError && error.code === "deployment-drift", + ); + await access(path.join(hostRoot, "rare-skill", "SKILL.md")); + + await writeFile(deployedGuide, "fixture guide\n", "utf8"); + const deactivated = await lifecycle.deactivate({ + name: "rare-skill", + target, + }); + assert.equal(deactivated.status, "deactivated"); + await assert.rejects(access(path.join(hostRoot, "rare-skill"))); + await access(path.join(fixture.managedRoot, "rare-skill", "SKILL.md")); +}); + +test("archive verifies a standalone skill before removing it from host discovery", async () => { + const fixture = await lifecycleFixture(); + const hostRoot = path.join(fixture.base, "active-skills"); + const active = await createStandaloneSkill(hostRoot, "archive-me"); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + const archived = await lifecycle.archive({ + source: "archive-me", + target: { host: "codex", scope: "custom", root: hostRoot }, + }); + assert.equal(archived.status, "stored"); + await assert.rejects(access(active)); + await access(path.join(fixture.managedRoot, "archive-me", "SKILL.md")); + assert.deepEqual( + await readdir(path.join(fixture.managedRoot, ".stash", "journal")), + [], + ); +}); + +test("deactivate refuses to remove an untracked detached directory", async () => { + const fixture = await lifecycleFixture(); + const hostRoot = path.join(fixture.base, "detached-host"); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + await lifecycle.install({ source: fixture.sourceRoot }); + await createStandaloneSkill(hostRoot, "rare-skill"); + await assert.rejects( + lifecycle.deactivate({ + name: "rare-skill", + target: { host: "codex", scope: "custom", root: hostRoot }, + }), + (error: unknown) => + error instanceof StashError && error.code === "detached-deployment", + ); + await access(path.join(hostRoot, "rare-skill", "SKILL.md")); +}); + +test("install rejects symlinks or junctions anywhere in the skill tree", async (t) => { + const fixture = await lifecycleFixture(); + const target = path.join(fixture.base, "linked-target"); + await mkdir(target, { recursive: true }); + try { + await symlink( + target, + path.join(fixture.sourceRoot, "linked"), + process.platform === "win32" ? "junction" : "dir", + ); + } catch (error) { + t.skip(`This platform cannot create a test link: ${String(error)}`); + return; + } + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + await assert.rejects( + lifecycle.install({ source: fixture.sourceRoot }), + (error: unknown) => + error instanceof StashError && error.code === "unsafe-skill-tree", + ); +}); + +test("status reports stored and deployed state without claiming host activation", async () => { + const fixture = await lifecycleFixture(); + const hostRoot = path.join(fixture.base, "status-host"); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + await lifecycle.install({ source: fixture.sourceRoot }); + await lifecycle.activate({ + name: "rare-skill", + target: { host: "claude-code", scope: "custom", root: hostRoot }, + }); + const status = await lifecycle.status({ name: "rare-skill" }); + assert.equal(status.status, "ok"); + assert.equal(status.skills[0]?.store.state, "stored"); + assert.equal(status.skills[0]?.store.integrity, "verified"); + assert.equal(status.skills[0]?.deployments[0]?.state, "deployed"); + assert.equal(status.skills[0]?.deployments[0]?.integrity, "verified"); + assert.equal( + status.skills[0]?.deployments[0]?.hostObservation.override, + "unknown", + ); + assert.equal( + status.skills[0]?.deployments[0]?.skillId, + status.skills[0]?.skillId, + ); +}); + +test("lifecycle rejects Antigravity CLI flat-file scopes", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + await lifecycle.install({ source: fixture.sourceRoot }); + for (const scope of ["user", "workspace"] as const) { + await assert.rejects( + lifecycle.activate({ + name: "rare-skill", + target: { host: "antigravity-cli", scope }, + }), + (error: unknown) => + error instanceof StashError && error.code === "unsupported-host-layout", + ); + } +}); + +test("archive requires an exact child of the selected standalone host root", async () => { + const fixture = await lifecycleFixture(); + const hostRoot = path.join(fixture.base, "selected-host"); + await mkdir(hostRoot, { recursive: true }); + const elsewhere = await createStandaloneSkill( + path.join(fixture.base, "elsewhere"), + "outside-skill", + ); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + await assert.rejects( + lifecycle.archive({ + source: elsewhere, + target: { host: "codex", scope: "custom", root: hostRoot }, + }), + (error: unknown) => + error instanceof StashError && error.code === "unsafe-archive-source", + ); + await access(path.join(elsewhere, "SKILL.md")); +}); + +test("managed projection folds a preserved catalog source without hiding scoped reads", async () => { + const fixture = await lifecycleFixture(); + const catalogRoot = path.dirname(fixture.sourceRoot); + const lifecycle = await createStashLifecycle({ + catalogs: [ + { + id: "active", + root: catalogRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + }, + ], + managedRoot: fixture.managedRoot, + }); + await lifecycle.install({ source: fixture.sourceRoot }); + const catalog = await createStashCatalog({ + catalogs: [ + { + id: "active", + root: catalogRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + }, + ], + managedRoot: fixture.managedRoot, + cacheDir: path.join(fixture.base, "projection-cache"), + }); + const projected = await catalog.resolve({ kind: "exact", name: "rare-skill" }); + assert.equal(projected.status, "ok"); + assert.equal(projected.matches.length, 1); + assert.equal(projected.matches[0]?.catalogId, "managed"); + assert.equal(projected.matches[0]?.relatedCopies?.[0]?.kind, "source"); + assert.equal(projected.matches[0]?.relatedCopies?.[0]?.catalogId, "active"); + + const scoped = await catalog.resolve({ + kind: "exact", + name: "rare-skill", + catalogIds: ["active"], + }); + assert.equal(scoped.status, "ok"); + assert.equal(scoped.matches[0]?.catalogId, "active"); + const read = await catalog.read({ ref: scoped.matches[0]?.ref ?? "" }); + assert.equal(read.status, "ok"); + + await writeFile( + path.join(fixture.sourceRoot, "references", "guide.md"), + "source drift\n", + "utf8", + ); + const drifted = await catalog.resolve({ kind: "exact", name: "rare-skill" }); + assert.equal(drifted.status, "ambiguous-exact"); + assert.equal(drifted.matches.length, 2); + assert.ok( + drifted.diagnostics.warnings?.some( + (warning) => warning.code === "managed-copy-drift", + ), + ); +}); + +test("a configured host catalog supports archive to activate round trips", async () => { + const fixture = await lifecycleFixture(); + const hostRoot = path.join(fixture.base, "configured-host"); + await createStandaloneSkill(hostRoot, "round-trip-skill"); + const registration = { + id: "host", + root: hostRoot, + enabled: true, + trust: "unreviewed" as const, + followSymlinks: false, + }; + const lifecycle = await createStashLifecycle({ + catalogs: [registration], + managedRoot: fixture.managedRoot, + }); + const target = { + host: "codex" as const, + scope: "custom" as const, + root: hostRoot, + }; + await lifecycle.archive({ source: "round-trip-skill", target }); + await lifecycle.activate({ name: "round-trip-skill", target }); + + const catalog = await createStashCatalog({ + catalogs: [registration], + managedRoot: fixture.managedRoot, + cacheDir: path.join(fixture.base, "round-trip-cache"), + }); + const projected = await catalog.resolve({ + kind: "exact", + name: "round-trip-skill", + }); + assert.equal(projected.status, "ok"); + assert.equal(projected.matches.length, 1); + assert.equal(projected.matches[0]?.catalogId, "managed"); + assert.equal(projected.matches[0]?.relatedCopies?.[0]?.kind, "deployment"); + + const scoped = await catalog.resolve({ + kind: "exact", + name: "round-trip-skill", + catalogIds: ["host"], + }); + assert.equal(scoped.status, "ok"); + assert.equal(scoped.matches[0]?.catalogId, "host"); +}); + +test("the next mutation deterministically restores an interrupted archive", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + const installed = await lifecycle.install({ source: fixture.sourceRoot }); + const tombstone = path.join( + fixture.base, + ".stash-archive-rare-skill-00000000-0000-4000-8000-000000000002", + ); + await rename(fixture.sourceRoot, tombstone); + const operationId = "00000000-0000-4000-8000-000000000001"; + const journalPath = path.join( + fixture.managedRoot, + ".stash", + "journal", + `${operationId}.json`, + ); + await writeFile( + journalPath, + `${JSON.stringify( + { + schemaVersion: 1, + operationId, + stage: "source-tombstoned", + source: fixture.sourceRoot, + tombstone, + name: "rare-skill", + treeHash: installed.treeHash, + managedPath: installed.managedPath, + managedExistedBefore: true, + createdAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + "utf8", + ); + + const secondSource = await createStandaloneSkill( + path.join(fixture.base, "second-source"), + "second-skill", + ); + await lifecycle.install({ source: secondSource }); + await access(path.join(fixture.sourceRoot, "SKILL.md")); + await assert.rejects(access(tombstone)); + await assert.rejects(access(journalPath)); +}); + +test("a dead-process lifecycle lock is recovered before the next mutation", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + await lifecycle.install({ source: fixture.sourceRoot }); + const lockPath = path.join( + fixture.managedRoot, + ".stash", + "lifecycle.lock", + ); + await mkdir(lockPath, { recursive: false }); + await writeFile( + path.join(lockPath, "owner.json"), + `${JSON.stringify({ + schemaVersion: 1, + ownerToken: "00000000-0000-4000-8000-000000000003", + pid: 2_147_483_647, + createdAt: new Date(0).toISOString(), + })}\n`, + "utf8", + ); + const repeated = await lifecycle.install({ source: fixture.sourceRoot }); + assert.equal(repeated.status, "already-stored"); + await assert.rejects(access(lockPath)); +}); + +test("malformed or live lifecycle ownership fails closed", async () => { + for (const owner of [ + "not-json", + JSON.stringify({ + schemaVersion: 1, + ownerToken: "00000000-0000-4000-8000-000000000004", + pid: process.pid, + createdAt: new Date(0).toISOString(), + }), + ]) { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + }); + await lifecycle.install({ source: fixture.sourceRoot }); + const lockPath = path.join( + fixture.managedRoot, + ".stash", + "lifecycle.lock", + ); + await mkdir(lockPath, { recursive: false }); + await writeFile(path.join(lockPath, "owner.json"), `${owner}\n`, "utf8"); + await assert.rejects( + lifecycle.install({ source: fixture.sourceRoot }), + (error: unknown) => + error instanceof StashError && + (error.code === "lifecycle-lock-corrupt" || + error.code === "lifecycle-busy"), + ); + await access(path.join(fixture.sourceRoot, "SKILL.md")); + } +}); From defdbe933d8e9fa55cf6ba23028f49f48ea2bfea Mon Sep 17 00:00:00 2001 From: dd3ok Date: Sat, 1 Aug 2026 09:45:46 +0900 Subject: [PATCH 2/2] fix: harden lifecycle review findings --- AGENTS.md | 13 +- CHANGELOG.md | 7 + CONTRIBUTING.md | 11 +- README.ko.md | 12 +- README.md | 11 +- SECURITY.md | 11 +- adapters/antigravity/cli/scripts/stash.mjs | 1156 +++++++++-------- adapters/antigravity/cli/skills/stash.md | 15 +- .../antigravity/ide/skills/stash/SKILL.md | 15 +- .../ide/skills/stash/scripts/stash.mjs | 1156 +++++++++-------- adapters/claude-code/skills/stash/SKILL.md | 15 +- .../skills/stash/scripts/stash.mjs | 1156 +++++++++-------- adapters/codex/skills/stash/SKILL.md | 15 +- adapters/codex/skills/stash/scripts/stash.mjs | 1156 +++++++++-------- docs/architecture.md | 21 +- docs/maintenance.md | 5 +- docs/vendor-support.md | 7 +- skills/stash/SKILL.md | 15 +- skills/stash/scripts/stash.mjs | 1156 +++++++++-------- src/cli.ts | 16 +- src/index.ts | 1 + src/internal/configuration.ts | 53 +- src/internal/lifecycle-host-policy.ts | 65 + src/internal/managed-projection.ts | 151 +-- src/internal/tree-fingerprint.ts | 233 ++++ src/internal/util.ts | 7 + src/stash-lifecycle.ts | 594 ++++----- src/types.ts | 5 +- tests-dist/cli.test.mjs | 24 +- tests/stash-lifecycle.test.ts | 313 ++++- 30 files changed, 4137 insertions(+), 3278 deletions(-) create mode 100644 src/internal/lifecycle-host-policy.ts create mode 100644 src/internal/tree-fingerprint.ts diff --git a/AGENTS.md b/AGENTS.md index 6b56790..a69579f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,15 @@ # Repository guidance -- Keep `StashCatalog` as the only public core Module Interface. -- Keep vendor behavior inside `adapters/` generation and contract tests. -- Treat configured catalogs as read-only; write only regenerable cache data. +- Keep `StashCatalog` as the only public read Module Interface; keep explicit + writes behind the separate `StashLifecycle` Interface. +- Keep vendor packaging and skill metadata inside `adapters/` generation. Keep + audited lifecycle discovery-root and reload policy in + `src/internal/lifecycle-host-policy.ts` with contract tests; never leak vendor + behavior into catalog search or safe-read path handling. +- Treat configured catalogs as read-only in every catalog operation. Only an + explicit lifecycle target may mutate its exact standalone child, even when + that target root is also registered for search; catalog registration alone + never grants write authority. - Preserve exact lookup as a deterministic path before discovery search. - Never impose a fixed cap on total relevant results; pagination is transport only. - Do not add vector search, an LLM router, a daemon, telemetry, or catalog mutation without measured evidence and an explicit scope decision. diff --git a/CHANGELOG.md b/CHANGELOG.md index b89563d..ba59283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ - Fold hash-matching catalog sources and Stash-owned deployments into the managed canonical search result while preserving scoped lookup and raw reads. - Reject workspace lifecycle and Antigravity CLI's flat standalone skill shapes. +- Restrict lifecycle deployment roots to documented vendor user directories; + arbitrary custom roots are no longer part of the public contract. +- Reject managed-store overlap with configured catalogs, including canonical + aliases, and fail closed when a lock owner's process state is unknown. +- Make archiving a verified tracked deployment deactivate it coherently while + preserving the managed canonical copy. +- Harden portable path validation and managed-record projection validation. - Keep external catalogs, plugins, and vendor settings outside lifecycle write authority; reject links, overwrites, detached removals, and remote sources. - Make a fresh Stash installation useful without catalog configuration by diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6a2c51..22a74fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,12 +19,17 @@ Validate the Codex skill and plugin with the commands and prerequisites in ## Change rules -- Keep the `StashCatalog` Interface small. -- Keep vendor logic out of core search and path handling. +- Keep the `StashCatalog` read Interface and `StashLifecycle` write Interface + small and authority-separated. +- Keep vendor packaging in generated Adapters. Centralize the narrow lifecycle + host-path/reload policy in `src/internal/lifecycle-host-policy.ts`, and keep it + out of catalog search and safe-read path handling. - Add a failing golden or Interface test before changing relevance behavior. - Preserve no-match abstention. - Preserve all-relevant totals independently from page size. -- Never add catalog mutation to a read path. +- Never add catalog mutation to a read path. An explicit lifecycle archive may + mutate only the exact standalone target selected by the caller; a catalog + registration never grants that authority. - Do not add remote calls or telemetry by default. - Update `docs/vendor-support.md` only from current first-party documentation and live contract tests. diff --git a/README.ko.md b/README.ko.md index a93d7e3..272c527 100644 --- a/README.ko.md +++ b/README.ko.md @@ -109,9 +109,10 @@ stash deactivate rare-skill --host codex `install`, `import`, `add`는 같은 명령입니다. `archive`는 파괴적 변형으로, 명시적으로 선택한 독립 스킬을 검증해 보관한 뒤 호스트 검색 경로의 원본을 -제거합니다. 플러그인에 포함된 스킬은 관리하지 않습니다. `activate` 결과는 -`deployed`로 기록하며 호스트의 별도 활성·비활성 설정까지 켜졌다고 단정하지 -않습니다. +제거합니다. 이미 검증된 Stash 소유 배포본이면 canonical 사본을 유지한 채 +`deactivate`와 같은 추적 철회를 수행합니다. 플러그인에 포함된 스킬은 +관리하지 않습니다. `activate` 결과는 `deployed`로 기록하며 호스트의 별도 +활성·비활성 설정까지 켜졌다고 단정하지 않습니다. CLI는 로컬 디렉터리만 가져옵니다. 사용자가 Stash 스킬에 저장소의 특정 스킬을 명시적으로 가져오라고 요청하면, 에이전트가 호스트 검색 경로 밖에 @@ -140,8 +141,9 @@ Antigravity 어댑터는 생성되지만, 지원을 공개하기 전에 대상 ` ## 범위 -- 외부 원본 보관함은 읽기 전용입니다. 쓰기는 Stash 관리형 보관소와 - 명시적으로 선택한 독립 스킬 생명주기 대상에만 허용합니다. +- catalog 작업은 외부 원본 보관함을 읽기 전용으로 유지합니다. 쓰기는 외부 + 보관함과 겹치지 않는 Stash 관리형 보관소와, 사용자가 정확히 선택한 독립 + 스킬 생명주기 대상에만 허용합니다. - 네트워크, embedding 모델, vector database, 별도 LLM 라우터를 사용하지 않습니다. - 스킬을 읽으면서 포함된 스크립트를 실행하지 않습니다. diff --git a/README.md b/README.md index 5c1751b..43ac9ef 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,10 @@ stash deactivate rare-skill --host codex `install`, `import`, and `add` are aliases. `archive` is the destructive form: it verifies and stores an explicitly selected standalone skill before removing that source directory from host discovery. It never manages a plugin-contained -skill. `activate` records a `deployed` copy; it does not claim that a host-level -enable/disable override is enabled. +skill. If the selected path is already a verified Stash-owned deployment, +`archive` performs the same tracked withdrawal as `deactivate` and preserves +the canonical copy. `activate` records a `deployed` copy; it does not claim that +a host-level enable/disable override is enabled. The CLI imports local directories only. When a user explicitly asks the Stash skill to import a repository skill, the agent may stage the pinned revision @@ -142,8 +144,9 @@ Antigravity adapters are generated, but should be tested against the target ## Boundaries -- External source libraries remain read-only. Writes are restricted to the - Stash-managed root and explicit standalone lifecycle targets. +- Catalog operations keep external source libraries read-only. Writes are + restricted to the non-overlapping Stash-managed root and exact standalone + lifecycle targets explicitly selected by the caller. - Search uses no network, embedding model, vector database, or second LLM router. - Reading a skill does not execute its scripts. diff --git a/SECURITY.md b/SECURITY.md index 5188bbf..3cec62b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,10 @@ Stash discovers, reads, and explicitly stores local Agent Skills. A skill can co ## Guarantees -- Stash never edits external configured catalog files. +- Catalog search, read, refresh, doctor, and install never edit external + configured catalog files. An explicit archive/deactivate target may share a + root with a search registration, but only that exact standalone child or a + verified Stash-owned deployment is writable. - Managed imports reject symlinks, junctions, special files, non-portable path names, case-insensitive collisions, oversized trees, and overwrites. - Lifecycle copies are staged and tree-hash verified before atomic rename. @@ -16,8 +19,10 @@ Stash discovers, reads, and explicitly stores local Agent Skills. A skill can co path, and a committed tombstone is deleted only after its tree hash matches. - Deactivation requires matching Stash ownership, skill/deployment identity, target, and tree hash. -- Catalog overlap grants no write authority. Hash-matching related copies are - folded only in the read projection; drifted or unrelated copies stay visible. +- Catalog registration grants no write authority. Hash-matching related copies + are folded only in the read projection; drifted or unrelated copies stay + visible. Managed storage itself must not equal, contain, or sit inside an + external catalog, including through a filesystem alias. - Lifecycle lock ownership is atomically published. Proven-dead owners are reclaimed under a separate guard; malformed or live ownership fails closed. - Indexing does not execute scripts. diff --git a/adapters/antigravity/cli/scripts/stash.mjs b/adapters/antigravity/cli/scripts/stash.mjs index c1d2319..dc6f863 100644 --- a/adapters/antigravity/cli/scripts/stash.mjs +++ b/adapters/antigravity/cli/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path7) { - const ctrl = callVisitor(key, node, visitor, path7); + function visit_(key, node, visitor, path9) { + const ctrl = callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visit_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visit_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path7); + const ci = visit_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = visit_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = visit_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path7); + const cv = visit_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path7) { - const ctrl = await callVisitor(key, node, visitor, path7); + async function visitAsync_(key, node, visitor, path9) { + const ctrl = await callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visitAsync_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visitAsync_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path7); + const ci = await visitAsync_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path7); + const cv = await visitAsync_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path7) { + function callVisitor(key, node, visitor, path9) { if (typeof visitor === "function") - return visitor(key, node, path7); + return visitor(key, node, path9); if (identity.isMap(node)) - return visitor.Map?.(key, node, path7); + return visitor.Map?.(key, node, path9); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path7); + return visitor.Seq?.(key, node, path9); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path7); + return visitor.Pair?.(key, node, path9); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path7); + return visitor.Scalar?.(key, node, path9); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path7); + return visitor.Alias?.(key, node, path9); return void 0; } - function replaceNode(key, path7, node) { - const parent = path7[path7.length - 1]; + function replaceNode(key, path9, node) { + const parent = path9[path9.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path7, value) { + function collectionFromPath(schema, path9, value) { let v = value; - for (let i = path7.length - 1; i >= 0; --i) { - const k = path7[i]; + for (let i = path9.length - 1; i >= 0; --i) { + const k = path9[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; + var isEmptyPath = (path9) => path9 == null || typeof path9 === "object" && !!path9[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path7, value) { - if (isEmptyPath(path7)) + addIn(path9, value) { + if (isEmptyPath(path9)) this.add(value); else { - const [key, ...rest] = path7; + const [key, ...rest] = path9; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - const [key, ...rest] = path7; + deleteIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - const [key, ...rest] = path7; + getIn(path9, keepScalar) { + const [key, ...rest] = path9; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path7) { - const [key, ...rest] = path7; + hasIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - const [key, ...rest] = path7; + setIn(path9, value) { + const [key, ...rest] = path9; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path7, value) { + addIn(path9, value) { if (assertCollection(this.contents)) - this.contents.addIn(path7, value); + this.contents.addIn(path9, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - if (Collection.isEmptyPath(path7)) { + deleteIn(path9) { + if (Collection.isEmptyPath(path9)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path9) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - if (Collection.isEmptyPath(path7)) + getIn(path9, keepScalar) { + if (Collection.isEmptyPath(path9)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path9, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path7) { - if (Collection.isEmptyPath(path7)) + hasIn(path9) { + if (Collection.isEmptyPath(path9)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path9) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - if (Collection.isEmptyPath(path7)) { + setIn(path9, value) { + if (Collection.isEmptyPath(path9)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path9), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path7, value); + this.contents.setIn(path9, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path7) => { + visit.itemAtPath = (cst, path9) => { let item = cst; - for (const [field, index] of path7) { + for (const [field, index] of path9) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path7) => { - const parent = visit.itemAtPath(cst, path7.slice(0, -1)); - const field = path7[path7.length - 1][0]; + visit.parentCollection = (cst, path9) => { + const parent = visit.itemAtPath(cst, path9.slice(0, -1)); + const field = path9[path9.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path7, item, visitor) { - let ctrl = visitor(item, path7); + function _visit(path9, item, visitor) { + let ctrl = visitor(item, path9); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path9.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path7); + ctrl = ctrl(item, path9); } } - return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path9) : ctrl; } exports.visit = visit; } @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; -import path5 from "node:path"; +import { readFile as readFile5, realpath as realpath4, stat as stat4 } from "node:fs/promises"; +import path6 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile, stat } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7484,6 +7484,10 @@ function isPathInside(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative); } +function pathIdentity(value) { + const normalized = path.resolve(value).normalize("NFKC"); + return platform() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} function normalizeRelativePath(value) { if (!value || path.isAbsolute(value)) { return void 0; @@ -7818,12 +7822,16 @@ async function includeManagedCatalog(catalogs, managedRoot) { 2 ); } - const normalizedManagedRoot = path2.resolve(managedRoot); - if (catalogs.some((catalog) => { - const normalizedCatalogRoot = path2.resolve(catalog.root); - return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; - })) { - return catalogs; + const canonicalManagedRoot = await canonicalPotentialPath(managedRoot); + for (const catalog of catalogs) { + const canonicalCatalogRoot = await canonicalPotentialPath(catalog.root); + if (isPathInside(canonicalCatalogRoot, canonicalManagedRoot) || isPathInside(canonicalManagedRoot, canonicalCatalogRoot)) { + throw new StashError( + "invalid-config", + `Managed root must be separate from external catalog "${catalog.id}": "${managedRoot}" overlaps "${catalog.root}".`, + 2 + ); + } } try { const info = await stat(managedRoot); @@ -7853,6 +7861,28 @@ async function includeManagedCatalog(catalogs, managedRoot) { } ]; } +async function canonicalPotentialPath(input) { + const absolute = path2.resolve(input); + const missingSegments = []; + let candidate = absolute; + while (true) { + try { + const canonical = await realpath(candidate); + return path2.resolve(canonical, ...missingSegments.reverse()); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + const parent = path2.dirname(candidate); + if (parent === candidate) { + return absolute; + } + missingSegments.push(path2.basename(candidate)); + candidate = parent; + } + } +} function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); for (const catalog of catalogs) { @@ -7874,7 +7904,7 @@ import { open, readFile as readFile2, readdir, - realpath, + realpath as realpath2, rename, stat as stat2, unlink, @@ -8054,7 +8084,7 @@ async function discoverCandidates(catalog) { const warnings = []; let root; try { - root = await realpath(path3.resolve(catalog.root)); + root = await realpath2(path3.resolve(catalog.root)); } catch (error) { throw new StashError( "catalog-unavailable", @@ -8077,7 +8107,7 @@ async function discoverCandidates(catalog) { if (depth > maxDepth) { return; } - const canonicalDirectory = await realpath(directory); + const canonicalDirectory = await realpath2(directory); if (visitedDirectories.has(canonicalDirectory)) { return; } @@ -8130,7 +8160,7 @@ async function discoverCandidates(catalog) { }); continue; } - const target = await realpath(entryPath); + const target = await realpath2(entryPath); if (!isPathInside(root, target)) { warnings.push({ code: "symlink-escape", @@ -8356,86 +8386,205 @@ async function acquireLock(lockPath) { } // src/internal/managed-projection.ts +import { + readFile as readFile4, + readdir as readdir3 +} from "node:fs/promises"; +import path5 from "node:path"; + +// src/internal/tree-fingerprint.ts import { lstat, readFile as readFile3, readdir as readdir2, - realpath as realpath2, + realpath as realpath3, stat as stat3 } from "node:fs/promises"; -import { platform as platform2 } from "node:os"; import path4 from "node:path"; var MAX_FILES = 1e4; var MAX_TOTAL_BYTES = 256 * 1024 * 1024; -function pathIdentity(value) { - const normalized = path4.resolve(value).normalize("NFKC"); - return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +var TreeFingerprintError = class extends Error { + failure; + relativePath; + detail; + constructor(failure, message, relativePath, detail) { + super(message); + this.name = "TreeFingerprintError"; + this.failure = failure; + this.relativePath = relativePath; + this.detail = detail; + } +}; +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment) || /[<>:"/\\|?*\u0000-\u001F\u007F]/u.test(segment)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is not portable: "${segment}".`, + segment + ); + } + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is reserved on Windows: "${segment}".`, + segment + ); + } } -function validRecord(value) { +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); +} +async function fingerprintTree(sourceRoot, capturePaths = /* @__PURE__ */ new Set()) { + const rootInput = path4.resolve(sourceRoot); + let rootInfo; + try { + rootInfo = await lstat(rootInput); + } catch (error) { + throw new TreeFingerprintError( + "root-unavailable", + `Tree root is unavailable: "${rootInput}".`, + void 0, + error + ); + } + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new TreeFingerprintError( + "unsafe-root", + `Tree root must be a real directory: "${rootInput}".` + ); + } + const root = await realpath3(rootInput); + const entries = []; + const captured = /* @__PURE__ */ new Map(); + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new TreeFingerprintError( + "path-collision", + `Case-insensitive path collision at "${relativePath}".`, + relativePath + ); + } + pathKeys.add(key); + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new TreeFingerprintError( + "linked-tree", + `Links are not allowed in a skill tree: "${relativePath}".`, + relativePath + ); + } + if (before.isDirectory()) { + const canonical = await realpath3(childPath); + if (!isPathInside(root, canonical)) { + throw new TreeFingerprintError( + "tree-escape", + `Directory escapes the skill root: "${relativePath}".`, + relativePath + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new TreeFingerprintError( + "special-file", + `Only regular files and directories are allowed: "${relativePath}".`, + relativePath + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new TreeFingerprintError( + "tree-too-large", + `Skill exceeds ${MAX_FILES} files or ${MAX_TOTAL_BYTES} bytes.`, + relativePath + ); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new TreeFingerprintError( + "tree-changed", + `Skill changed while it was being read: "${relativePath}".`, + relativePath + ); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + if (capturePaths.has(relativePath)) { + captured.set(relativePath, content); + } + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + captured + }; +} + +// src/internal/managed-projection.ts +function validRecord(value, expectedName) { if (!value || typeof value !== "object") { return false; } const record = value; - return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); + return record.schemaVersion === 1 && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === expectedName && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(record.name) && /^sha256:[0-9a-f]{64}$/u.test(record.treeHash) && record.source !== null && typeof record.source === "object" && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path5.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.url === void 0 || typeof record.source.url === "string") && (record.source.revision === void 0 || typeof record.source.revision === "string") && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => deployment !== null && typeof deployment === "object" && typeof deployment.deploymentId === "string" && deployment.deploymentId.length > 0 && deployment.skillId === record.skillId && (deployment.host === "codex" || deployment.host === "claude-code" || deployment.host === "antigravity-ide") && (deployment.scope === "user" || deployment.scope === "workspace") && deployment.method === "copy" && deployment.ownership === "stash" && typeof deployment.root === "string" && path5.isAbsolute(deployment.root) && typeof deployment.path === "string" && path5.isAbsolute(deployment.path) && pathIdentity(deployment.path) === pathIdentity(path5.join(deployment.root, record.name)) && deployment.targetId === `${deployment.host}:${deployment.scope}:${pathIdentity(deployment.root)}` && /^sha256:[0-9a-f]{64}$/u.test(deployment.treeHash) && typeof deployment.deployedAt === "string" + ); } async function treeHash(rootInput) { try { - const rootInfo = await lstat(rootInput); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - return void 0; - } - const root = await realpath2(rootInput); - const entries = []; - let files = 0; - let totalBytes = 0; - async function walk(directory, relativeDirectory) { - const children = await readdir2(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const childPath = path4.join(directory, child.name); - const before = await lstat(childPath); - if (before.isSymbolicLink()) { - throw new Error("linked tree"); - } - if (before.isDirectory()) { - const canonical = await realpath2(childPath); - if (!isPathInside(root, canonical)) { - throw new Error("tree escape"); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new Error("special file"); - } - files += 1; - totalBytes += before.size; - if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { - throw new Error("tree too large"); - } - const content = await readFile3(childPath); - const after = await stat3(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new Error("tree changed"); - } - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash: sha256(content) - }); - } - } - await walk(root, ""); - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); - return sha256(fingerprint); + return (await fingerprintTree(rootInput)).treeHash; } catch { return void 0; } @@ -8473,9 +8622,9 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { return { indexes, fingerprintPart: "" }; } let recordFiles; - const recordsRoot = path4.join(managedRoot, ".stash", "records"); + const recordsRoot = path5.join(managedRoot, ".stash", "records"); try { - recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + recordFiles = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); } catch { return { indexes, fingerprintPart: "" }; } @@ -8484,9 +8633,10 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { for (const file of recordFiles) { try { const parsed = JSON.parse( - await readFile3(path4.join(recordsRoot, file), "utf8") + await readFile4(path5.join(recordsRoot, file), "utf8") ); - if (!validRecord(parsed)) { + const expectedName = file.slice(0, -".json".length); + if (!validRecord(parsed, expectedName)) { throw new Error("invalid record"); } managedRecords.set(parsed.name, parsed); @@ -8513,24 +8663,32 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { }); } } + const events = []; const canonicalBySkillId = /* @__PURE__ */ new Map(); for (const record of managedIndex.records) { const managedRecord = managedRecords.get(record.name); if (managedRecord) { record.managedSkillId = managedRecord.skillId; record.relatedCopies = []; + record.source = { + ...record.source, + ...managedRecord.source.url ? { url: managedRecord.source.url } : {}, + ...managedRecord.source.revision ? { revision: managedRecord.source.revision } : {} + }; + events.push( + `record:${managedRecord.skillId}:${managedRecord.source.url ?? ""}:${managedRecord.source.revision ?? ""}` + ); canonicalBySkillId.set(managedRecord.skillId, record); } } - const events = []; for (const index of indexes) { if (index.catalogId === "managed") { continue; } const visible = []; for (const record of index.records) { - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); const target = targets.get(pathIdentity(skillRoot)); const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; @@ -9257,10 +9415,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path5.dirname( - path5.resolve(index.root, record.relativeSkillFile) + const skillRoot = path6.dirname( + path6.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path5.resolve(skillRoot, normalizedResource); + const candidatePath = path6.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9272,7 +9430,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath3(candidatePath); + resolvedPath = await realpath4(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9333,7 +9491,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile4(resolvedPath); + const buffer = await readFile5(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9454,45 +9612,67 @@ import { randomUUID } from "node:crypto"; import { lstat as lstat2, mkdir as mkdir2, - readFile as readFile5, - readdir as readdir3, - realpath as realpath4, + readFile as readFile6, + readdir as readdir4, + realpath as realpath5, rename as rename2, rm, - stat as stat5, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises"; -import { homedir as homedir2, platform as platform3 } from "node:os"; -import path6 from "node:path"; +import { homedir as homedir3 } from "node:os"; +import path8 from "node:path"; + +// src/internal/lifecycle-host-policy.ts +import { homedir as homedir2 } from "node:os"; +import path7 from "node:path"; +function resolveLifecycleTarget(target, homeDirectory = homedir2()) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace" || target.workspace) { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + const scope = target.scope ?? "user"; + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".gemini", "config", "skills") + }; + } +} +function lifecycleReloadRequired(host) { + return host !== "claude-code"; +} +function lifecycleRefreshObservation(host) { + return lifecycleReloadRequired(host) ? "restart-required" : "live"; +} + +// src/stash-lifecycle.ts var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -var MAX_FILES2 = 1e4; -var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; var STORE_SCHEMA_VERSION = 1; -var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ - "con", - "prn", - "aux", - "nul", - "com1", - "com2", - "com3", - "com4", - "com5", - "com6", - "com7", - "com8", - "com9", - "lpt1", - "lpt2", - "lpt3", - "lpt4", - "lpt5", - "lpt6", - "lpt7", - "lpt8", - "lpt9" -]); function asObject3(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } @@ -9546,143 +9726,45 @@ function parseSkillMetadata(snapshot) { } }; } -function portablePathKey(relativePath) { - return relativePath.normalize("NFKC").toLocaleLowerCase("und"); -} -function validatePortableSegment(segment) { - if (!segment || /[. ]$/u.test(segment)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot end in a dot or space: "${segment}".`, - 3 - ); - } - const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; - if (WINDOWS_RESERVED_NAMES.has(base)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot use the reserved name "${segment}".`, - 3 - ); - } -} async function snapshotTree(sourceRoot) { - const rootInput = path6.resolve(sourceRoot); - const rootInfo = await lstat2(rootInput).catch((error) => { - throw new StashError( - "skill-unavailable", - `Skill directory is unavailable at "${rootInput}": ${String(error)}`, - 4 - ); - }); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - throw new StashError( - "unsafe-skill-tree", - `Skill root must be a real directory, not a link: "${rootInput}".`, - 3 + let fingerprint; + try { + fingerprint = await fingerprintTree( + sourceRoot, + /* @__PURE__ */ new Set(["SKILL.md", "stash.meta.yaml"]) ); - } - const root = await realpath4(rootInput); - const entries = []; - const pathKeys = /* @__PURE__ */ new Set(); - let fileCount = 0; - let totalBytes = 0; - let skillSource; - let sidecarSource; - async function walk(directory, relativeDirectory) { - const children = await readdir3(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - validatePortableSegment(child.name); - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const key = portablePathKey(relativePath); - if (pathKeys.has(key)) { - throw new StashError( - "unsafe-skill-tree", - `Case-insensitive path collision at "${relativePath}".`, - 3 - ); - } - pathKeys.add(key); - const childPath = path6.join(directory, child.name); - const before = await lstat2(childPath); - if (before.isSymbolicLink()) { - throw new StashError( - "unsafe-skill-tree", - `Symlinks and junctions are not allowed: "${relativePath}".`, - 3 - ); - } - if (before.isDirectory()) { - const canonical = await realpath4(childPath); - if (!isPathInside(root, canonical)) { - throw new StashError( - "unsafe-skill-tree", - `Directory escapes the skill root: "${relativePath}".`, - 3 - ); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new StashError( - "unsafe-skill-tree", - `Only regular files and directories are allowed: "${relativePath}".`, - 3 - ); - } - fileCount += 1; - totalBytes += before.size; - if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { - throw new StashError( - "skill-too-large", - `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, - 3 - ); - } - const content = await readFile5(childPath); - const after = await stat5(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new StashError( - "source-changed", - `Skill changed while it was being read: "${relativePath}".`, - 4 - ); - } - const contentHash = sha256(content); - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash - }); - if (relativePath === "SKILL.md") { - skillSource = content.toString("utf8"); - } else if (relativePath === "stash.meta.yaml") { - sidecarSource = content.toString("utf8"); - } + } catch (error) { + if (!(error instanceof TreeFingerprintError)) { + throw error; } + if (error.failure === "root-unavailable") { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${path8.resolve(sourceRoot)}": ${String(error.detail ?? error.message)}`, + 4 + ); + } + if (error.failure === "tree-too-large") { + throw new StashError("skill-too-large", error.message, 3); + } + if (error.failure === "tree-changed") { + throw new StashError("source-changed", error.message, 4); + } + throw new StashError("unsafe-skill-tree", error.message, 3); } - await walk(root, ""); + const skillSource = fingerprint.captured.get("SKILL.md")?.toString("utf8"); if (skillSource === void 0) { throw new StashError( "invalid-skill", - `Skill root must contain SKILL.md: "${root}".`, + `Skill root must contain SKILL.md: "${fingerprint.root}".`, 3 ); } - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); + const sidecarSource = fingerprint.captured.get("stash.meta.yaml")?.toString("utf8"); return { - root, - treeHash: sha256(fingerprint), - entries, + root: fingerprint.root, + treeHash: fingerprint.treeHash, + entries: fingerprint.entries, skillSource, ...sidecarSource !== void 0 ? { sidecarSource } : {} }; @@ -9690,14 +9772,14 @@ async function snapshotTree(sourceRoot) { async function copySnapshot(snapshot, destination) { await mkdir2(destination, { recursive: false }); for (const entry of snapshot.entries) { - const relative = entry.relativePath.split("/").join(path6.sep); - const target = path6.join(destination, relative); + const relative = entry.relativePath.split("/").join(path8.sep); + const target = path8.join(destination, relative); if (entry.kind === "directory") { await mkdir2(target, { recursive: false }); continue; } - const source = path6.join(snapshot.root, relative); - const content = await readFile5(source); + const source = path8.join(snapshot.root, relative); + const content = await readFile6(source); if (sha256(content) !== entry.contentHash) { throw new StashError( "source-changed", @@ -9727,82 +9809,25 @@ async function pathType(target) { } } function samePath(left, right) { - const normalizedLeft = path6.resolve(left).normalize("NFKC"); - const normalizedRight = path6.resolve(right).normalize("NFKC"); - return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; -} -function pathIdentity2(value) { - const normalized = path6.resolve(value).normalize("NFKC"); - return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; + return pathIdentity(left) === pathIdentity(right); } function targetIdentity(target) { - return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; -} -function resolveHostRoot(target) { - if (target.host === "antigravity-cli") { - throw new StashError( - "unsupported-host-layout", - "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", - 2 - ); - } - if (target.scope === "workspace") { - throw new StashError( - "unsupported-host-scope", - "Workspace lifecycle targets are not supported in this release.", - 2 - ); - } - if (target.root) { - return { - host: target.host, - scope: target.scope ?? "custom", - root: path6.resolve(target.root) - }; - } - const scope = target.scope ?? "user"; - if (scope === "custom") { - throw new StashError( - "invalid-argument", - "A custom lifecycle target requires root.", - 2 - ); - } - switch (target.host) { - case "codex": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".agents", "skills") - }; - case "claude-code": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".claude", "skills") - }; - case "antigravity-ide": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".gemini", "config", "skills") - }; - } + return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } async function isPluginContained(source) { - let current = path6.dirname(source); + let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { const markers = [ - path6.join(current, ".claude-plugin", "plugin.json"), - path6.join(current, ".codex-plugin", "plugin.json"), - path6.join(current, "plugin.json") + path8.join(current, ".claude-plugin", "plugin.json"), + path8.join(current, ".codex-plugin", "plugin.json"), + path8.join(current, "plugin.json") ]; for (const marker of markers) { if (await pathType(marker) !== "missing") { return true; } } - const parent = path6.dirname(current); + const parent = path8.dirname(current); if (parent === current) { break; } @@ -9813,21 +9838,22 @@ async function isPluginContained(source) { var StashLifecycleImplementation = class { #managedRoot; #now; + #lifecycleHome = path8.resolve(homedir3()); constructor(managedRoot, now) { - this.#managedRoot = path6.resolve(managedRoot); + this.#managedRoot = path8.resolve(managedRoot); this.#now = now; } #metadataRoot() { - return path6.join(this.#managedRoot, ".stash"); + return path8.join(this.#managedRoot, ".stash"); } #recordPath(name) { - return path6.join(this.#metadataRoot(), "records", `${name}.json`); + return path8.join(this.#metadataRoot(), "records", `${name}.json`); } async #ensureLayout() { - await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); - const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + await mkdir2(path8.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path8.join(this.#managedRoot, "stash.catalog.yaml"); try { await writeFile2( manifestPath, @@ -9842,7 +9868,7 @@ var StashLifecycleImplementation = class { } } #journalPath(operationId) { - return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + return path8.join(this.#metadataRoot(), "journal", `${operationId}.json`); } async #writeJournal(journal) { const finalPath = this.#journalPath(journal.operationId); @@ -9901,16 +9927,16 @@ var StashLifecycleImplementation = class { "source-tombstoned", "archive-committed" ]); - if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path8.isAbsolute(journal.source) || !path8.isAbsolute(journal.tombstone) || !path8.isAbsolute(journal.managedPath)) { throw new StashError( "invalid-lifecycle-journal", `Invalid or unsafe lifecycle journal "${journalPath}".`, 5 ); } - const expectedManagedPath = path6.join(this.#managedRoot, journal.name); - const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); - if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + const expectedManagedPath = path8.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path8.dirname(path8.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path8.dirname(journal.tombstone), expectedTombstoneParent) || !path8.basename(journal.tombstone).startsWith( `.stash-archive-${journal.name}-` ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { throw new StashError( @@ -9975,13 +10001,13 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); } async #recoverJournals() { - const journalRoot = path6.join(this.#metadataRoot(), "journal"); - const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + const journalRoot = path8.join(this.#metadataRoot(), "journal"); + const files = (await readdir4(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); for (const file of files) { - const journalPath = path6.join(journalRoot, file); + const journalPath = path8.join(journalRoot, file); let journal; try { - journal = JSON.parse(await readFile5(journalPath, "utf8")); + journal = JSON.parse(await readFile6(journalPath, "utf8")); } catch (error) { throw new StashError( "invalid-lifecycle-journal", @@ -9997,7 +10023,7 @@ var StashLifecycleImplementation = class { let owner; try { owner = JSON.parse( - await readFile5(path6.join(lockPath, "owner.json"), "utf8") + await readFile6(path8.join(lockPath, "owner.json"), "utf8") ); } catch (error) { throw new StashError( @@ -10015,23 +10041,24 @@ var StashLifecycleImplementation = class { } return owner; } - #ownerIsAlive(owner) { + #ownerState(owner) { try { process.kill(owner.pid, 0); - return true; - } catch { - return false; + return "alive"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + return code === "ESRCH" ? "dead" : "unknown"; } } async #publishLock(lockPath, owner) { - const temporaryPath = path6.join( + const temporaryPath = path8.join( this.#metadataRoot(), `.lifecycle-lock-${owner.ownerToken}.tmp` ); await mkdir2(temporaryPath, { recursive: false }); try { await writeFile2( - path6.join(temporaryPath, "owner.json"), + path8.join(temporaryPath, "owner.json"), `${JSON.stringify(owner)} `, { encoding: "utf8", flag: "wx" } @@ -10052,7 +10079,7 @@ var StashLifecycleImplementation = class { } } async #reclaimDeadLock(lockPath) { - const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + const guardPath = path8.join(this.#metadataRoot(), "lifecycle.reclaim"); try { await mkdir2(guardPath, { recursive: false }); } catch (error) { @@ -10077,14 +10104,14 @@ var StashLifecycleImplementation = class { ); } const owner = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(owner)) { + if (this.#ownerState(owner) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } - const reclaimedPath = path6.join( + const reclaimedPath = path8.join( this.#metadataRoot(), `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` ); @@ -10116,10 +10143,10 @@ var StashLifecycleImplementation = class { return owner; } const existing = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(existing)) { + if (this.#ownerState(existing) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } @@ -10144,7 +10171,7 @@ var StashLifecycleImplementation = class { } async #withLock(operation) { await this.#ensureLayout(); - const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const lockPath = path8.join(this.#metadataRoot(), "lifecycle.lock"); const owner = await this.#acquireLock(lockPath); try { await this.#recoverJournals(); @@ -10156,9 +10183,9 @@ var StashLifecycleImplementation = class { async #readRecord(name) { const recordPath = this.#recordPath(name); try { - const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + const parsed = JSON.parse(await readFile6(recordPath, "utf8")); if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) )) { throw new Error("invalid lifecycle record shape"); } @@ -10191,8 +10218,8 @@ var StashLifecycleImplementation = class { } } async #assertSourceBoundary(source) { - const canonicalSource = await realpath4(source); - const canonicalManaged = await realpath4(this.#managedRoot); + const canonicalSource = await realpath5(source); + const canonicalManaged = await realpath5(this.#managedRoot); if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { throw new StashError( "unsafe-source", @@ -10208,7 +10235,7 @@ var StashLifecycleImplementation = class { type = await pathType(root); } if (type === "missing" && allowMissing) { - return path6.resolve(root); + return path8.resolve(root); } if (type !== "directory") { throw new StashError( @@ -10217,7 +10244,7 @@ var StashLifecycleImplementation = class { 3 ); } - return realpath4(root); + return realpath5(root); } async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { await this.#assertSourceBoundary(source); @@ -10230,7 +10257,7 @@ var StashLifecycleImplementation = class { ); } const metadata = parseSkillMetadata(snapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedPath = path8.join(this.#managedRoot, metadata.name); const existingType = await pathType(managedPath); if (existingType !== "missing") { if (existingType !== "directory") { @@ -10262,7 +10289,7 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + const stage = path8.join(this.#metadataRoot(), "staging", randomUUID()); let committed = false; try { await copySnapshot(snapshot, stage); @@ -10311,7 +10338,7 @@ var StashLifecycleImplementation = class { async install(request) { return this.#withLock(async () => { const stored = await this.#storeSource( - path6.resolve(request.source), + path8.resolve(request.source), "local-import", request.sourceUrl, request.revision @@ -10327,16 +10354,19 @@ var StashLifecycleImplementation = class { } async archive(request) { return this.#withLock(async () => { - const resolvedTarget = resolveHostRoot(request.target); + const resolvedTarget = resolveLifecycleTarget( + request.target, + this.#lifecycleHome + ); resolvedTarget.root = await this.#canonicalHostRoot( resolvedTarget.root, false ); const looksLikeName = NAME_PATTERN2.test(request.source); - const source = path6.resolve( - looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + const source = path8.resolve( + looksLikeName ? path8.join(resolvedTarget.root, request.source) : request.source ); - if (!samePath(path6.dirname(source), resolvedTarget.root)) { + if (!samePath(path8.dirname(source), resolvedTarget.root)) { throw new StashError( "unsafe-archive-source", `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, @@ -10353,11 +10383,39 @@ var StashLifecycleImplementation = class { await this.#assertSourceBoundary(source); const sourceSnapshot = await snapshotTree(source); const metadata = parseSkillMetadata(sourceSnapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); - const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; - const tombstoneParent = path6.dirname(resolvedTarget.root); + const managedPath = path8.join(this.#managedRoot, metadata.name); + const existingRecord = await this.#readRecord(metadata.name); + const managedType = await pathType(managedPath); + const selectedTargetId = targetIdentity(resolvedTarget); + const trackedDeployment = existingRecord?.deployments.find( + (deployment) => samePath(deployment.path, source) && deployment.targetId === selectedTargetId + ); + if (existingRecord && trackedDeployment) { + if (managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${metadata.name}" is unavailable; refusing to archive its tracked deployment.`, + 3 + ); + } + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== existingRecord.treeHash || sourceSnapshot.treeHash !== existingRecord.treeHash || trackedDeployment.treeHash !== existingRecord.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill or tracked deployment "${metadata.name}" drifted; refusing archive.`, + 3 + ); + } + return this.#deactivateDeployment( + existingRecord, + managedPath, + resolvedTarget + ); + } + const managedExistedBefore = managedType !== "missing" || existingRecord !== void 0; + const tombstoneParent = path8.dirname(resolvedTarget.root); await mkdir2(tombstoneParent, { recursive: true }); - const tombstone = path6.join( + const tombstone = path8.join( tombstoneParent, `.stash-archive-${metadata.name}-${randomUUID()}` ); @@ -10444,7 +10502,7 @@ var StashLifecycleImplementation = class { 4 ); } - const managedPath = path6.join(this.#managedRoot, request.name); + const managedPath = path8.join(this.#managedRoot, request.name); const managedSnapshot = await snapshotTree(managedPath); if (managedSnapshot.treeHash !== record.treeHash) { throw new StashError( @@ -10453,9 +10511,9 @@ var StashLifecycleImplementation = class { 3 ); } - const target = resolveHostRoot(request.target); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); target.root = await this.#canonicalHostRoot(target.root, true); - const deploymentPath = path6.join(target.root, request.name); + const deploymentPath = path8.join(target.root, request.name); const targetId = targetIdentity(target); const existing = await pathType(deploymentPath); const tracked = record.deployments.find( @@ -10472,7 +10530,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment: tracked, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } @@ -10483,8 +10541,8 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join( - path6.dirname(target.root), + const stage = path8.join( + path8.dirname(target.root), `.stash-deploy-${record.name}-${randomUUID()}` ); let committed = false; @@ -10535,7 +10593,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } finally { @@ -10545,120 +10603,123 @@ var StashLifecycleImplementation = class { } }); } - async deactivate(request) { - return this.#withLock(async () => { - const record = await this.#readRecord(request.name); - if (!record) { - throw new StashError( - "managed-skill-not-found", - `Managed skill "${request.name}" was not found.`, - 4 - ); - } - const managedPath = path6.join(this.#managedRoot, request.name); - const target = resolveHostRoot(request.target); - target.root = await this.#canonicalHostRoot(target.root, false, true); - const deploymentPath = path6.join(target.root, request.name); - const targetId = targetIdentity(target); - const deployment = record.deployments.find( - (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + async #deactivateDeployment(record, managedPath, target) { + const deploymentPath = path8.join(target.root, record.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 ); - if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { - throw new StashError( - "detached-deployment", - `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, - 3 - ); - } - const existing = await pathType(deploymentPath); - if (existing === "missing") { - record.deployments = record.deployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - await this.#writeRecord(record); - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - warning: "The tracked deployment was already missing." - }; - } - if (existing !== "directory") { - throw new StashError( - "deployment-drift", - `Tracked deployment is no longer a real directory: "${deploymentPath}".`, - 3 - ); - } - const deployedSnapshot = await snapshotTree(deploymentPath); - if (deployedSnapshot.treeHash !== deployment.treeHash) { + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path8.join( + path8.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); throw new StashError( "deployment-drift", - `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, 3 ); } - const tombstone = path6.join( - path6.dirname(target.root), - `.stash-deactivate-${record.name}-${randomUUID()}` + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) ); - await rename2(deploymentPath, tombstone); + record.lastValidatedAt = new Date(this.#now()).toISOString(); try { - const movedSnapshot = await snapshotTree(tombstone); - if (movedSnapshot.treeHash !== deployment.treeHash) { - await rename2(tombstone, deploymentPath); - throw new StashError( - "deployment-drift", - `Deployment changed during deactivation and was restored: "${deploymentPath}".`, - 3 - ); - } - const previousDeployments = record.deployments; - record.deployments = previousDeployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - try { - await this.#writeRecord(record); - } catch (error) { - record.deployments = previousDeployments; - await rename2(tombstone, deploymentPath).catch(() => void 0); - throw error; - } - let warning; - try { - await rm(tombstone, { recursive: true, force: false }); - } catch (error) { - warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; - } - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - reloadRequired: target.host !== "claude-code", - ...warning ? { warning } : {} - }; + await this.#writeRecord(record); } catch (error) { - if (await pathType(tombstone) !== "missing") { - await rename2(tombstone, deploymentPath).catch(() => void 0); - } + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); throw error; } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: lifecycleReloadRequired(target.host), + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path8.join(this.#managedRoot, request.name); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); + target.root = await this.#canonicalHostRoot(target.root, false, true); + return this.#deactivateDeployment(record, managedPath, target); }); } async status(request = {}) { - const recordsRoot = path6.join(this.#metadataRoot(), "records"); + const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; if (request.name) { names = [request.name]; } else { try { - names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + names = (await readdir4(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); } catch (error) { const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; if (code === "ENOENT") { @@ -10674,7 +10735,7 @@ var StashLifecycleImplementation = class { if (!record) { continue; } - const managedPath = path6.join(this.#managedRoot, name); + const managedPath = path8.join(this.#managedRoot, name); const managedType = await pathType(managedPath); let storeState = managedType === "missing" ? "missing" : "stored"; let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; @@ -10698,7 +10759,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "absent", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10711,7 +10772,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10726,7 +10787,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "present", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } catch { @@ -10737,7 +10798,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } @@ -10764,8 +10825,14 @@ var StashLifecycleImplementation = class { } }; async function createStashLifecycle(options = {}) { - const loaded = await loadConfiguration(options); - const managedRoot = path6.resolve( + const effectiveOptions = options.catalogs && !options.managedRoot ? { + ...options, + managedRoot: path8.resolve( + process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ) + } : options; + const loaded = await loadConfiguration(effectiveOptions); + const managedRoot = path8.resolve( loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() ); return new StashLifecycleImplementation( @@ -10865,19 +10932,25 @@ function lifecycleTarget(args) { ); } const scope = flag(args, "scope"); - if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + if (scope !== void 0 && scope !== "user" && scope !== "workspace") { throw new StashError( "invalid-argument", - "--scope must be user, workspace, or custom.", + "--scope must be user or workspace.", 2 ); } const root = flag(args, "host-root"); + if (root) { + throw new StashError( + "unsupported-host-root", + "Custom host roots are not supported; lifecycle targets use documented user skill directories.", + 2 + ); + } const workspace = flag(args, "workspace"); return { host, ...scope ? { scope } : {}, - ...root ? { root } : {}, ...workspace ? { workspace } : {} }; } @@ -10968,8 +11041,7 @@ Configuration: Lifecycle targeting: --host codex, claude-code, antigravity-ide, or antigravity-cli. - --scope user or custom (default: user; workspace is rejected). - --host-root Explicit discovery root; implies a custom location. + --scope user (default); workspace is rejected in this release. Result pagination never caps the total relevant result set. Lifecycle commands manage only the Stash-owned store and explicitly selected diff --git a/adapters/antigravity/cli/skills/stash.md b/adapters/antigravity/cli/skills/stash.md index bf4f106..15d76b4 100644 --- a/adapters/antigravity/cli/skills/stash.md +++ b/adapters/antigravity/cli/skills/stash.md @@ -114,17 +114,20 @@ only the temporary staging directory after a successful managed import. ### Archive a standalone skill -Resolve exactly one standalone skill directory and require its host root: +Resolve exactly one standalone skill directory under the host's documented +user skill root: ```text node archive --host [--scope user] --json ``` -An explicit directory path still requires `--host`; use `--scope custom ---host-root ` when selecting a non-default discovery root. The source -must be an exact child of that root. Explain that archive removes the source -only after a journaled copy, validation, hash check, and commit. Never archive -a plugin-contained skill; delegate plugin lifecycle to the host. +The source must be an exact child of the documented user root. Arbitrary custom +roots and workspace roots are unsupported because Stash cannot prove that the +host discovers them. Explain that archive removes the source only after a +journaled copy, validation, hash check, and commit. Never archive a +plugin-contained skill; delegate plugin lifecycle to the host. If the exact +path is already a verified Stash-owned deployment, archive must use tracked +deactivation semantics and preserve the canonical copy. ### Deploy or withdraw a managed copy diff --git a/adapters/antigravity/ide/skills/stash/SKILL.md b/adapters/antigravity/ide/skills/stash/SKILL.md index 8987d74..d313d90 100644 --- a/adapters/antigravity/ide/skills/stash/SKILL.md +++ b/adapters/antigravity/ide/skills/stash/SKILL.md @@ -114,17 +114,20 @@ only the temporary staging directory after a successful managed import. ### Archive a standalone skill -Resolve exactly one standalone skill directory and require its host root: +Resolve exactly one standalone skill directory under the host's documented +user skill root: ```text node archive --host [--scope user] --json ``` -An explicit directory path still requires `--host`; use `--scope custom ---host-root ` when selecting a non-default discovery root. The source -must be an exact child of that root. Explain that archive removes the source -only after a journaled copy, validation, hash check, and commit. Never archive -a plugin-contained skill; delegate plugin lifecycle to the host. +The source must be an exact child of the documented user root. Arbitrary custom +roots and workspace roots are unsupported because Stash cannot prove that the +host discovers them. Explain that archive removes the source only after a +journaled copy, validation, hash check, and commit. Never archive a +plugin-contained skill; delegate plugin lifecycle to the host. If the exact +path is already a verified Stash-owned deployment, archive must use tracked +deactivation semantics and preserve the canonical copy. ### Deploy or withdraw a managed copy diff --git a/adapters/antigravity/ide/skills/stash/scripts/stash.mjs b/adapters/antigravity/ide/skills/stash/scripts/stash.mjs index c1d2319..dc6f863 100644 --- a/adapters/antigravity/ide/skills/stash/scripts/stash.mjs +++ b/adapters/antigravity/ide/skills/stash/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path7) { - const ctrl = callVisitor(key, node, visitor, path7); + function visit_(key, node, visitor, path9) { + const ctrl = callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visit_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visit_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path7); + const ci = visit_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = visit_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = visit_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path7); + const cv = visit_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path7) { - const ctrl = await callVisitor(key, node, visitor, path7); + async function visitAsync_(key, node, visitor, path9) { + const ctrl = await callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visitAsync_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visitAsync_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path7); + const ci = await visitAsync_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path7); + const cv = await visitAsync_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path7) { + function callVisitor(key, node, visitor, path9) { if (typeof visitor === "function") - return visitor(key, node, path7); + return visitor(key, node, path9); if (identity.isMap(node)) - return visitor.Map?.(key, node, path7); + return visitor.Map?.(key, node, path9); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path7); + return visitor.Seq?.(key, node, path9); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path7); + return visitor.Pair?.(key, node, path9); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path7); + return visitor.Scalar?.(key, node, path9); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path7); + return visitor.Alias?.(key, node, path9); return void 0; } - function replaceNode(key, path7, node) { - const parent = path7[path7.length - 1]; + function replaceNode(key, path9, node) { + const parent = path9[path9.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path7, value) { + function collectionFromPath(schema, path9, value) { let v = value; - for (let i = path7.length - 1; i >= 0; --i) { - const k = path7[i]; + for (let i = path9.length - 1; i >= 0; --i) { + const k = path9[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; + var isEmptyPath = (path9) => path9 == null || typeof path9 === "object" && !!path9[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path7, value) { - if (isEmptyPath(path7)) + addIn(path9, value) { + if (isEmptyPath(path9)) this.add(value); else { - const [key, ...rest] = path7; + const [key, ...rest] = path9; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - const [key, ...rest] = path7; + deleteIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - const [key, ...rest] = path7; + getIn(path9, keepScalar) { + const [key, ...rest] = path9; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path7) { - const [key, ...rest] = path7; + hasIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - const [key, ...rest] = path7; + setIn(path9, value) { + const [key, ...rest] = path9; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path7, value) { + addIn(path9, value) { if (assertCollection(this.contents)) - this.contents.addIn(path7, value); + this.contents.addIn(path9, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - if (Collection.isEmptyPath(path7)) { + deleteIn(path9) { + if (Collection.isEmptyPath(path9)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path9) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - if (Collection.isEmptyPath(path7)) + getIn(path9, keepScalar) { + if (Collection.isEmptyPath(path9)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path9, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path7) { - if (Collection.isEmptyPath(path7)) + hasIn(path9) { + if (Collection.isEmptyPath(path9)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path9) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - if (Collection.isEmptyPath(path7)) { + setIn(path9, value) { + if (Collection.isEmptyPath(path9)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path9), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path7, value); + this.contents.setIn(path9, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path7) => { + visit.itemAtPath = (cst, path9) => { let item = cst; - for (const [field, index] of path7) { + for (const [field, index] of path9) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path7) => { - const parent = visit.itemAtPath(cst, path7.slice(0, -1)); - const field = path7[path7.length - 1][0]; + visit.parentCollection = (cst, path9) => { + const parent = visit.itemAtPath(cst, path9.slice(0, -1)); + const field = path9[path9.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path7, item, visitor) { - let ctrl = visitor(item, path7); + function _visit(path9, item, visitor) { + let ctrl = visitor(item, path9); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path9.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path7); + ctrl = ctrl(item, path9); } } - return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path9) : ctrl; } exports.visit = visit; } @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; -import path5 from "node:path"; +import { readFile as readFile5, realpath as realpath4, stat as stat4 } from "node:fs/promises"; +import path6 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile, stat } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7484,6 +7484,10 @@ function isPathInside(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative); } +function pathIdentity(value) { + const normalized = path.resolve(value).normalize("NFKC"); + return platform() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} function normalizeRelativePath(value) { if (!value || path.isAbsolute(value)) { return void 0; @@ -7818,12 +7822,16 @@ async function includeManagedCatalog(catalogs, managedRoot) { 2 ); } - const normalizedManagedRoot = path2.resolve(managedRoot); - if (catalogs.some((catalog) => { - const normalizedCatalogRoot = path2.resolve(catalog.root); - return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; - })) { - return catalogs; + const canonicalManagedRoot = await canonicalPotentialPath(managedRoot); + for (const catalog of catalogs) { + const canonicalCatalogRoot = await canonicalPotentialPath(catalog.root); + if (isPathInside(canonicalCatalogRoot, canonicalManagedRoot) || isPathInside(canonicalManagedRoot, canonicalCatalogRoot)) { + throw new StashError( + "invalid-config", + `Managed root must be separate from external catalog "${catalog.id}": "${managedRoot}" overlaps "${catalog.root}".`, + 2 + ); + } } try { const info = await stat(managedRoot); @@ -7853,6 +7861,28 @@ async function includeManagedCatalog(catalogs, managedRoot) { } ]; } +async function canonicalPotentialPath(input) { + const absolute = path2.resolve(input); + const missingSegments = []; + let candidate = absolute; + while (true) { + try { + const canonical = await realpath(candidate); + return path2.resolve(canonical, ...missingSegments.reverse()); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + const parent = path2.dirname(candidate); + if (parent === candidate) { + return absolute; + } + missingSegments.push(path2.basename(candidate)); + candidate = parent; + } + } +} function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); for (const catalog of catalogs) { @@ -7874,7 +7904,7 @@ import { open, readFile as readFile2, readdir, - realpath, + realpath as realpath2, rename, stat as stat2, unlink, @@ -8054,7 +8084,7 @@ async function discoverCandidates(catalog) { const warnings = []; let root; try { - root = await realpath(path3.resolve(catalog.root)); + root = await realpath2(path3.resolve(catalog.root)); } catch (error) { throw new StashError( "catalog-unavailable", @@ -8077,7 +8107,7 @@ async function discoverCandidates(catalog) { if (depth > maxDepth) { return; } - const canonicalDirectory = await realpath(directory); + const canonicalDirectory = await realpath2(directory); if (visitedDirectories.has(canonicalDirectory)) { return; } @@ -8130,7 +8160,7 @@ async function discoverCandidates(catalog) { }); continue; } - const target = await realpath(entryPath); + const target = await realpath2(entryPath); if (!isPathInside(root, target)) { warnings.push({ code: "symlink-escape", @@ -8356,86 +8386,205 @@ async function acquireLock(lockPath) { } // src/internal/managed-projection.ts +import { + readFile as readFile4, + readdir as readdir3 +} from "node:fs/promises"; +import path5 from "node:path"; + +// src/internal/tree-fingerprint.ts import { lstat, readFile as readFile3, readdir as readdir2, - realpath as realpath2, + realpath as realpath3, stat as stat3 } from "node:fs/promises"; -import { platform as platform2 } from "node:os"; import path4 from "node:path"; var MAX_FILES = 1e4; var MAX_TOTAL_BYTES = 256 * 1024 * 1024; -function pathIdentity(value) { - const normalized = path4.resolve(value).normalize("NFKC"); - return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +var TreeFingerprintError = class extends Error { + failure; + relativePath; + detail; + constructor(failure, message, relativePath, detail) { + super(message); + this.name = "TreeFingerprintError"; + this.failure = failure; + this.relativePath = relativePath; + this.detail = detail; + } +}; +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment) || /[<>:"/\\|?*\u0000-\u001F\u007F]/u.test(segment)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is not portable: "${segment}".`, + segment + ); + } + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is reserved on Windows: "${segment}".`, + segment + ); + } } -function validRecord(value) { +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); +} +async function fingerprintTree(sourceRoot, capturePaths = /* @__PURE__ */ new Set()) { + const rootInput = path4.resolve(sourceRoot); + let rootInfo; + try { + rootInfo = await lstat(rootInput); + } catch (error) { + throw new TreeFingerprintError( + "root-unavailable", + `Tree root is unavailable: "${rootInput}".`, + void 0, + error + ); + } + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new TreeFingerprintError( + "unsafe-root", + `Tree root must be a real directory: "${rootInput}".` + ); + } + const root = await realpath3(rootInput); + const entries = []; + const captured = /* @__PURE__ */ new Map(); + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new TreeFingerprintError( + "path-collision", + `Case-insensitive path collision at "${relativePath}".`, + relativePath + ); + } + pathKeys.add(key); + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new TreeFingerprintError( + "linked-tree", + `Links are not allowed in a skill tree: "${relativePath}".`, + relativePath + ); + } + if (before.isDirectory()) { + const canonical = await realpath3(childPath); + if (!isPathInside(root, canonical)) { + throw new TreeFingerprintError( + "tree-escape", + `Directory escapes the skill root: "${relativePath}".`, + relativePath + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new TreeFingerprintError( + "special-file", + `Only regular files and directories are allowed: "${relativePath}".`, + relativePath + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new TreeFingerprintError( + "tree-too-large", + `Skill exceeds ${MAX_FILES} files or ${MAX_TOTAL_BYTES} bytes.`, + relativePath + ); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new TreeFingerprintError( + "tree-changed", + `Skill changed while it was being read: "${relativePath}".`, + relativePath + ); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + if (capturePaths.has(relativePath)) { + captured.set(relativePath, content); + } + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + captured + }; +} + +// src/internal/managed-projection.ts +function validRecord(value, expectedName) { if (!value || typeof value !== "object") { return false; } const record = value; - return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); + return record.schemaVersion === 1 && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === expectedName && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(record.name) && /^sha256:[0-9a-f]{64}$/u.test(record.treeHash) && record.source !== null && typeof record.source === "object" && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path5.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.url === void 0 || typeof record.source.url === "string") && (record.source.revision === void 0 || typeof record.source.revision === "string") && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => deployment !== null && typeof deployment === "object" && typeof deployment.deploymentId === "string" && deployment.deploymentId.length > 0 && deployment.skillId === record.skillId && (deployment.host === "codex" || deployment.host === "claude-code" || deployment.host === "antigravity-ide") && (deployment.scope === "user" || deployment.scope === "workspace") && deployment.method === "copy" && deployment.ownership === "stash" && typeof deployment.root === "string" && path5.isAbsolute(deployment.root) && typeof deployment.path === "string" && path5.isAbsolute(deployment.path) && pathIdentity(deployment.path) === pathIdentity(path5.join(deployment.root, record.name)) && deployment.targetId === `${deployment.host}:${deployment.scope}:${pathIdentity(deployment.root)}` && /^sha256:[0-9a-f]{64}$/u.test(deployment.treeHash) && typeof deployment.deployedAt === "string" + ); } async function treeHash(rootInput) { try { - const rootInfo = await lstat(rootInput); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - return void 0; - } - const root = await realpath2(rootInput); - const entries = []; - let files = 0; - let totalBytes = 0; - async function walk(directory, relativeDirectory) { - const children = await readdir2(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const childPath = path4.join(directory, child.name); - const before = await lstat(childPath); - if (before.isSymbolicLink()) { - throw new Error("linked tree"); - } - if (before.isDirectory()) { - const canonical = await realpath2(childPath); - if (!isPathInside(root, canonical)) { - throw new Error("tree escape"); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new Error("special file"); - } - files += 1; - totalBytes += before.size; - if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { - throw new Error("tree too large"); - } - const content = await readFile3(childPath); - const after = await stat3(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new Error("tree changed"); - } - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash: sha256(content) - }); - } - } - await walk(root, ""); - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); - return sha256(fingerprint); + return (await fingerprintTree(rootInput)).treeHash; } catch { return void 0; } @@ -8473,9 +8622,9 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { return { indexes, fingerprintPart: "" }; } let recordFiles; - const recordsRoot = path4.join(managedRoot, ".stash", "records"); + const recordsRoot = path5.join(managedRoot, ".stash", "records"); try { - recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + recordFiles = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); } catch { return { indexes, fingerprintPart: "" }; } @@ -8484,9 +8633,10 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { for (const file of recordFiles) { try { const parsed = JSON.parse( - await readFile3(path4.join(recordsRoot, file), "utf8") + await readFile4(path5.join(recordsRoot, file), "utf8") ); - if (!validRecord(parsed)) { + const expectedName = file.slice(0, -".json".length); + if (!validRecord(parsed, expectedName)) { throw new Error("invalid record"); } managedRecords.set(parsed.name, parsed); @@ -8513,24 +8663,32 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { }); } } + const events = []; const canonicalBySkillId = /* @__PURE__ */ new Map(); for (const record of managedIndex.records) { const managedRecord = managedRecords.get(record.name); if (managedRecord) { record.managedSkillId = managedRecord.skillId; record.relatedCopies = []; + record.source = { + ...record.source, + ...managedRecord.source.url ? { url: managedRecord.source.url } : {}, + ...managedRecord.source.revision ? { revision: managedRecord.source.revision } : {} + }; + events.push( + `record:${managedRecord.skillId}:${managedRecord.source.url ?? ""}:${managedRecord.source.revision ?? ""}` + ); canonicalBySkillId.set(managedRecord.skillId, record); } } - const events = []; for (const index of indexes) { if (index.catalogId === "managed") { continue; } const visible = []; for (const record of index.records) { - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); const target = targets.get(pathIdentity(skillRoot)); const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; @@ -9257,10 +9415,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path5.dirname( - path5.resolve(index.root, record.relativeSkillFile) + const skillRoot = path6.dirname( + path6.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path5.resolve(skillRoot, normalizedResource); + const candidatePath = path6.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9272,7 +9430,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath3(candidatePath); + resolvedPath = await realpath4(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9333,7 +9491,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile4(resolvedPath); + const buffer = await readFile5(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9454,45 +9612,67 @@ import { randomUUID } from "node:crypto"; import { lstat as lstat2, mkdir as mkdir2, - readFile as readFile5, - readdir as readdir3, - realpath as realpath4, + readFile as readFile6, + readdir as readdir4, + realpath as realpath5, rename as rename2, rm, - stat as stat5, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises"; -import { homedir as homedir2, platform as platform3 } from "node:os"; -import path6 from "node:path"; +import { homedir as homedir3 } from "node:os"; +import path8 from "node:path"; + +// src/internal/lifecycle-host-policy.ts +import { homedir as homedir2 } from "node:os"; +import path7 from "node:path"; +function resolveLifecycleTarget(target, homeDirectory = homedir2()) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace" || target.workspace) { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + const scope = target.scope ?? "user"; + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".gemini", "config", "skills") + }; + } +} +function lifecycleReloadRequired(host) { + return host !== "claude-code"; +} +function lifecycleRefreshObservation(host) { + return lifecycleReloadRequired(host) ? "restart-required" : "live"; +} + +// src/stash-lifecycle.ts var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -var MAX_FILES2 = 1e4; -var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; var STORE_SCHEMA_VERSION = 1; -var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ - "con", - "prn", - "aux", - "nul", - "com1", - "com2", - "com3", - "com4", - "com5", - "com6", - "com7", - "com8", - "com9", - "lpt1", - "lpt2", - "lpt3", - "lpt4", - "lpt5", - "lpt6", - "lpt7", - "lpt8", - "lpt9" -]); function asObject3(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } @@ -9546,143 +9726,45 @@ function parseSkillMetadata(snapshot) { } }; } -function portablePathKey(relativePath) { - return relativePath.normalize("NFKC").toLocaleLowerCase("und"); -} -function validatePortableSegment(segment) { - if (!segment || /[. ]$/u.test(segment)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot end in a dot or space: "${segment}".`, - 3 - ); - } - const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; - if (WINDOWS_RESERVED_NAMES.has(base)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot use the reserved name "${segment}".`, - 3 - ); - } -} async function snapshotTree(sourceRoot) { - const rootInput = path6.resolve(sourceRoot); - const rootInfo = await lstat2(rootInput).catch((error) => { - throw new StashError( - "skill-unavailable", - `Skill directory is unavailable at "${rootInput}": ${String(error)}`, - 4 - ); - }); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - throw new StashError( - "unsafe-skill-tree", - `Skill root must be a real directory, not a link: "${rootInput}".`, - 3 + let fingerprint; + try { + fingerprint = await fingerprintTree( + sourceRoot, + /* @__PURE__ */ new Set(["SKILL.md", "stash.meta.yaml"]) ); - } - const root = await realpath4(rootInput); - const entries = []; - const pathKeys = /* @__PURE__ */ new Set(); - let fileCount = 0; - let totalBytes = 0; - let skillSource; - let sidecarSource; - async function walk(directory, relativeDirectory) { - const children = await readdir3(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - validatePortableSegment(child.name); - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const key = portablePathKey(relativePath); - if (pathKeys.has(key)) { - throw new StashError( - "unsafe-skill-tree", - `Case-insensitive path collision at "${relativePath}".`, - 3 - ); - } - pathKeys.add(key); - const childPath = path6.join(directory, child.name); - const before = await lstat2(childPath); - if (before.isSymbolicLink()) { - throw new StashError( - "unsafe-skill-tree", - `Symlinks and junctions are not allowed: "${relativePath}".`, - 3 - ); - } - if (before.isDirectory()) { - const canonical = await realpath4(childPath); - if (!isPathInside(root, canonical)) { - throw new StashError( - "unsafe-skill-tree", - `Directory escapes the skill root: "${relativePath}".`, - 3 - ); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new StashError( - "unsafe-skill-tree", - `Only regular files and directories are allowed: "${relativePath}".`, - 3 - ); - } - fileCount += 1; - totalBytes += before.size; - if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { - throw new StashError( - "skill-too-large", - `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, - 3 - ); - } - const content = await readFile5(childPath); - const after = await stat5(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new StashError( - "source-changed", - `Skill changed while it was being read: "${relativePath}".`, - 4 - ); - } - const contentHash = sha256(content); - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash - }); - if (relativePath === "SKILL.md") { - skillSource = content.toString("utf8"); - } else if (relativePath === "stash.meta.yaml") { - sidecarSource = content.toString("utf8"); - } + } catch (error) { + if (!(error instanceof TreeFingerprintError)) { + throw error; } + if (error.failure === "root-unavailable") { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${path8.resolve(sourceRoot)}": ${String(error.detail ?? error.message)}`, + 4 + ); + } + if (error.failure === "tree-too-large") { + throw new StashError("skill-too-large", error.message, 3); + } + if (error.failure === "tree-changed") { + throw new StashError("source-changed", error.message, 4); + } + throw new StashError("unsafe-skill-tree", error.message, 3); } - await walk(root, ""); + const skillSource = fingerprint.captured.get("SKILL.md")?.toString("utf8"); if (skillSource === void 0) { throw new StashError( "invalid-skill", - `Skill root must contain SKILL.md: "${root}".`, + `Skill root must contain SKILL.md: "${fingerprint.root}".`, 3 ); } - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); + const sidecarSource = fingerprint.captured.get("stash.meta.yaml")?.toString("utf8"); return { - root, - treeHash: sha256(fingerprint), - entries, + root: fingerprint.root, + treeHash: fingerprint.treeHash, + entries: fingerprint.entries, skillSource, ...sidecarSource !== void 0 ? { sidecarSource } : {} }; @@ -9690,14 +9772,14 @@ async function snapshotTree(sourceRoot) { async function copySnapshot(snapshot, destination) { await mkdir2(destination, { recursive: false }); for (const entry of snapshot.entries) { - const relative = entry.relativePath.split("/").join(path6.sep); - const target = path6.join(destination, relative); + const relative = entry.relativePath.split("/").join(path8.sep); + const target = path8.join(destination, relative); if (entry.kind === "directory") { await mkdir2(target, { recursive: false }); continue; } - const source = path6.join(snapshot.root, relative); - const content = await readFile5(source); + const source = path8.join(snapshot.root, relative); + const content = await readFile6(source); if (sha256(content) !== entry.contentHash) { throw new StashError( "source-changed", @@ -9727,82 +9809,25 @@ async function pathType(target) { } } function samePath(left, right) { - const normalizedLeft = path6.resolve(left).normalize("NFKC"); - const normalizedRight = path6.resolve(right).normalize("NFKC"); - return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; -} -function pathIdentity2(value) { - const normalized = path6.resolve(value).normalize("NFKC"); - return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; + return pathIdentity(left) === pathIdentity(right); } function targetIdentity(target) { - return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; -} -function resolveHostRoot(target) { - if (target.host === "antigravity-cli") { - throw new StashError( - "unsupported-host-layout", - "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", - 2 - ); - } - if (target.scope === "workspace") { - throw new StashError( - "unsupported-host-scope", - "Workspace lifecycle targets are not supported in this release.", - 2 - ); - } - if (target.root) { - return { - host: target.host, - scope: target.scope ?? "custom", - root: path6.resolve(target.root) - }; - } - const scope = target.scope ?? "user"; - if (scope === "custom") { - throw new StashError( - "invalid-argument", - "A custom lifecycle target requires root.", - 2 - ); - } - switch (target.host) { - case "codex": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".agents", "skills") - }; - case "claude-code": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".claude", "skills") - }; - case "antigravity-ide": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".gemini", "config", "skills") - }; - } + return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } async function isPluginContained(source) { - let current = path6.dirname(source); + let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { const markers = [ - path6.join(current, ".claude-plugin", "plugin.json"), - path6.join(current, ".codex-plugin", "plugin.json"), - path6.join(current, "plugin.json") + path8.join(current, ".claude-plugin", "plugin.json"), + path8.join(current, ".codex-plugin", "plugin.json"), + path8.join(current, "plugin.json") ]; for (const marker of markers) { if (await pathType(marker) !== "missing") { return true; } } - const parent = path6.dirname(current); + const parent = path8.dirname(current); if (parent === current) { break; } @@ -9813,21 +9838,22 @@ async function isPluginContained(source) { var StashLifecycleImplementation = class { #managedRoot; #now; + #lifecycleHome = path8.resolve(homedir3()); constructor(managedRoot, now) { - this.#managedRoot = path6.resolve(managedRoot); + this.#managedRoot = path8.resolve(managedRoot); this.#now = now; } #metadataRoot() { - return path6.join(this.#managedRoot, ".stash"); + return path8.join(this.#managedRoot, ".stash"); } #recordPath(name) { - return path6.join(this.#metadataRoot(), "records", `${name}.json`); + return path8.join(this.#metadataRoot(), "records", `${name}.json`); } async #ensureLayout() { - await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); - const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + await mkdir2(path8.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path8.join(this.#managedRoot, "stash.catalog.yaml"); try { await writeFile2( manifestPath, @@ -9842,7 +9868,7 @@ var StashLifecycleImplementation = class { } } #journalPath(operationId) { - return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + return path8.join(this.#metadataRoot(), "journal", `${operationId}.json`); } async #writeJournal(journal) { const finalPath = this.#journalPath(journal.operationId); @@ -9901,16 +9927,16 @@ var StashLifecycleImplementation = class { "source-tombstoned", "archive-committed" ]); - if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path8.isAbsolute(journal.source) || !path8.isAbsolute(journal.tombstone) || !path8.isAbsolute(journal.managedPath)) { throw new StashError( "invalid-lifecycle-journal", `Invalid or unsafe lifecycle journal "${journalPath}".`, 5 ); } - const expectedManagedPath = path6.join(this.#managedRoot, journal.name); - const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); - if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + const expectedManagedPath = path8.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path8.dirname(path8.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path8.dirname(journal.tombstone), expectedTombstoneParent) || !path8.basename(journal.tombstone).startsWith( `.stash-archive-${journal.name}-` ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { throw new StashError( @@ -9975,13 +10001,13 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); } async #recoverJournals() { - const journalRoot = path6.join(this.#metadataRoot(), "journal"); - const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + const journalRoot = path8.join(this.#metadataRoot(), "journal"); + const files = (await readdir4(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); for (const file of files) { - const journalPath = path6.join(journalRoot, file); + const journalPath = path8.join(journalRoot, file); let journal; try { - journal = JSON.parse(await readFile5(journalPath, "utf8")); + journal = JSON.parse(await readFile6(journalPath, "utf8")); } catch (error) { throw new StashError( "invalid-lifecycle-journal", @@ -9997,7 +10023,7 @@ var StashLifecycleImplementation = class { let owner; try { owner = JSON.parse( - await readFile5(path6.join(lockPath, "owner.json"), "utf8") + await readFile6(path8.join(lockPath, "owner.json"), "utf8") ); } catch (error) { throw new StashError( @@ -10015,23 +10041,24 @@ var StashLifecycleImplementation = class { } return owner; } - #ownerIsAlive(owner) { + #ownerState(owner) { try { process.kill(owner.pid, 0); - return true; - } catch { - return false; + return "alive"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + return code === "ESRCH" ? "dead" : "unknown"; } } async #publishLock(lockPath, owner) { - const temporaryPath = path6.join( + const temporaryPath = path8.join( this.#metadataRoot(), `.lifecycle-lock-${owner.ownerToken}.tmp` ); await mkdir2(temporaryPath, { recursive: false }); try { await writeFile2( - path6.join(temporaryPath, "owner.json"), + path8.join(temporaryPath, "owner.json"), `${JSON.stringify(owner)} `, { encoding: "utf8", flag: "wx" } @@ -10052,7 +10079,7 @@ var StashLifecycleImplementation = class { } } async #reclaimDeadLock(lockPath) { - const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + const guardPath = path8.join(this.#metadataRoot(), "lifecycle.reclaim"); try { await mkdir2(guardPath, { recursive: false }); } catch (error) { @@ -10077,14 +10104,14 @@ var StashLifecycleImplementation = class { ); } const owner = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(owner)) { + if (this.#ownerState(owner) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } - const reclaimedPath = path6.join( + const reclaimedPath = path8.join( this.#metadataRoot(), `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` ); @@ -10116,10 +10143,10 @@ var StashLifecycleImplementation = class { return owner; } const existing = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(existing)) { + if (this.#ownerState(existing) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } @@ -10144,7 +10171,7 @@ var StashLifecycleImplementation = class { } async #withLock(operation) { await this.#ensureLayout(); - const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const lockPath = path8.join(this.#metadataRoot(), "lifecycle.lock"); const owner = await this.#acquireLock(lockPath); try { await this.#recoverJournals(); @@ -10156,9 +10183,9 @@ var StashLifecycleImplementation = class { async #readRecord(name) { const recordPath = this.#recordPath(name); try { - const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + const parsed = JSON.parse(await readFile6(recordPath, "utf8")); if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) )) { throw new Error("invalid lifecycle record shape"); } @@ -10191,8 +10218,8 @@ var StashLifecycleImplementation = class { } } async #assertSourceBoundary(source) { - const canonicalSource = await realpath4(source); - const canonicalManaged = await realpath4(this.#managedRoot); + const canonicalSource = await realpath5(source); + const canonicalManaged = await realpath5(this.#managedRoot); if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { throw new StashError( "unsafe-source", @@ -10208,7 +10235,7 @@ var StashLifecycleImplementation = class { type = await pathType(root); } if (type === "missing" && allowMissing) { - return path6.resolve(root); + return path8.resolve(root); } if (type !== "directory") { throw new StashError( @@ -10217,7 +10244,7 @@ var StashLifecycleImplementation = class { 3 ); } - return realpath4(root); + return realpath5(root); } async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { await this.#assertSourceBoundary(source); @@ -10230,7 +10257,7 @@ var StashLifecycleImplementation = class { ); } const metadata = parseSkillMetadata(snapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedPath = path8.join(this.#managedRoot, metadata.name); const existingType = await pathType(managedPath); if (existingType !== "missing") { if (existingType !== "directory") { @@ -10262,7 +10289,7 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + const stage = path8.join(this.#metadataRoot(), "staging", randomUUID()); let committed = false; try { await copySnapshot(snapshot, stage); @@ -10311,7 +10338,7 @@ var StashLifecycleImplementation = class { async install(request) { return this.#withLock(async () => { const stored = await this.#storeSource( - path6.resolve(request.source), + path8.resolve(request.source), "local-import", request.sourceUrl, request.revision @@ -10327,16 +10354,19 @@ var StashLifecycleImplementation = class { } async archive(request) { return this.#withLock(async () => { - const resolvedTarget = resolveHostRoot(request.target); + const resolvedTarget = resolveLifecycleTarget( + request.target, + this.#lifecycleHome + ); resolvedTarget.root = await this.#canonicalHostRoot( resolvedTarget.root, false ); const looksLikeName = NAME_PATTERN2.test(request.source); - const source = path6.resolve( - looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + const source = path8.resolve( + looksLikeName ? path8.join(resolvedTarget.root, request.source) : request.source ); - if (!samePath(path6.dirname(source), resolvedTarget.root)) { + if (!samePath(path8.dirname(source), resolvedTarget.root)) { throw new StashError( "unsafe-archive-source", `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, @@ -10353,11 +10383,39 @@ var StashLifecycleImplementation = class { await this.#assertSourceBoundary(source); const sourceSnapshot = await snapshotTree(source); const metadata = parseSkillMetadata(sourceSnapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); - const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; - const tombstoneParent = path6.dirname(resolvedTarget.root); + const managedPath = path8.join(this.#managedRoot, metadata.name); + const existingRecord = await this.#readRecord(metadata.name); + const managedType = await pathType(managedPath); + const selectedTargetId = targetIdentity(resolvedTarget); + const trackedDeployment = existingRecord?.deployments.find( + (deployment) => samePath(deployment.path, source) && deployment.targetId === selectedTargetId + ); + if (existingRecord && trackedDeployment) { + if (managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${metadata.name}" is unavailable; refusing to archive its tracked deployment.`, + 3 + ); + } + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== existingRecord.treeHash || sourceSnapshot.treeHash !== existingRecord.treeHash || trackedDeployment.treeHash !== existingRecord.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill or tracked deployment "${metadata.name}" drifted; refusing archive.`, + 3 + ); + } + return this.#deactivateDeployment( + existingRecord, + managedPath, + resolvedTarget + ); + } + const managedExistedBefore = managedType !== "missing" || existingRecord !== void 0; + const tombstoneParent = path8.dirname(resolvedTarget.root); await mkdir2(tombstoneParent, { recursive: true }); - const tombstone = path6.join( + const tombstone = path8.join( tombstoneParent, `.stash-archive-${metadata.name}-${randomUUID()}` ); @@ -10444,7 +10502,7 @@ var StashLifecycleImplementation = class { 4 ); } - const managedPath = path6.join(this.#managedRoot, request.name); + const managedPath = path8.join(this.#managedRoot, request.name); const managedSnapshot = await snapshotTree(managedPath); if (managedSnapshot.treeHash !== record.treeHash) { throw new StashError( @@ -10453,9 +10511,9 @@ var StashLifecycleImplementation = class { 3 ); } - const target = resolveHostRoot(request.target); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); target.root = await this.#canonicalHostRoot(target.root, true); - const deploymentPath = path6.join(target.root, request.name); + const deploymentPath = path8.join(target.root, request.name); const targetId = targetIdentity(target); const existing = await pathType(deploymentPath); const tracked = record.deployments.find( @@ -10472,7 +10530,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment: tracked, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } @@ -10483,8 +10541,8 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join( - path6.dirname(target.root), + const stage = path8.join( + path8.dirname(target.root), `.stash-deploy-${record.name}-${randomUUID()}` ); let committed = false; @@ -10535,7 +10593,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } finally { @@ -10545,120 +10603,123 @@ var StashLifecycleImplementation = class { } }); } - async deactivate(request) { - return this.#withLock(async () => { - const record = await this.#readRecord(request.name); - if (!record) { - throw new StashError( - "managed-skill-not-found", - `Managed skill "${request.name}" was not found.`, - 4 - ); - } - const managedPath = path6.join(this.#managedRoot, request.name); - const target = resolveHostRoot(request.target); - target.root = await this.#canonicalHostRoot(target.root, false, true); - const deploymentPath = path6.join(target.root, request.name); - const targetId = targetIdentity(target); - const deployment = record.deployments.find( - (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + async #deactivateDeployment(record, managedPath, target) { + const deploymentPath = path8.join(target.root, record.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 ); - if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { - throw new StashError( - "detached-deployment", - `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, - 3 - ); - } - const existing = await pathType(deploymentPath); - if (existing === "missing") { - record.deployments = record.deployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - await this.#writeRecord(record); - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - warning: "The tracked deployment was already missing." - }; - } - if (existing !== "directory") { - throw new StashError( - "deployment-drift", - `Tracked deployment is no longer a real directory: "${deploymentPath}".`, - 3 - ); - } - const deployedSnapshot = await snapshotTree(deploymentPath); - if (deployedSnapshot.treeHash !== deployment.treeHash) { + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path8.join( + path8.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); throw new StashError( "deployment-drift", - `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, 3 ); } - const tombstone = path6.join( - path6.dirname(target.root), - `.stash-deactivate-${record.name}-${randomUUID()}` + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) ); - await rename2(deploymentPath, tombstone); + record.lastValidatedAt = new Date(this.#now()).toISOString(); try { - const movedSnapshot = await snapshotTree(tombstone); - if (movedSnapshot.treeHash !== deployment.treeHash) { - await rename2(tombstone, deploymentPath); - throw new StashError( - "deployment-drift", - `Deployment changed during deactivation and was restored: "${deploymentPath}".`, - 3 - ); - } - const previousDeployments = record.deployments; - record.deployments = previousDeployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - try { - await this.#writeRecord(record); - } catch (error) { - record.deployments = previousDeployments; - await rename2(tombstone, deploymentPath).catch(() => void 0); - throw error; - } - let warning; - try { - await rm(tombstone, { recursive: true, force: false }); - } catch (error) { - warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; - } - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - reloadRequired: target.host !== "claude-code", - ...warning ? { warning } : {} - }; + await this.#writeRecord(record); } catch (error) { - if (await pathType(tombstone) !== "missing") { - await rename2(tombstone, deploymentPath).catch(() => void 0); - } + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); throw error; } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: lifecycleReloadRequired(target.host), + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path8.join(this.#managedRoot, request.name); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); + target.root = await this.#canonicalHostRoot(target.root, false, true); + return this.#deactivateDeployment(record, managedPath, target); }); } async status(request = {}) { - const recordsRoot = path6.join(this.#metadataRoot(), "records"); + const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; if (request.name) { names = [request.name]; } else { try { - names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + names = (await readdir4(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); } catch (error) { const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; if (code === "ENOENT") { @@ -10674,7 +10735,7 @@ var StashLifecycleImplementation = class { if (!record) { continue; } - const managedPath = path6.join(this.#managedRoot, name); + const managedPath = path8.join(this.#managedRoot, name); const managedType = await pathType(managedPath); let storeState = managedType === "missing" ? "missing" : "stored"; let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; @@ -10698,7 +10759,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "absent", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10711,7 +10772,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10726,7 +10787,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "present", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } catch { @@ -10737,7 +10798,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } @@ -10764,8 +10825,14 @@ var StashLifecycleImplementation = class { } }; async function createStashLifecycle(options = {}) { - const loaded = await loadConfiguration(options); - const managedRoot = path6.resolve( + const effectiveOptions = options.catalogs && !options.managedRoot ? { + ...options, + managedRoot: path8.resolve( + process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ) + } : options; + const loaded = await loadConfiguration(effectiveOptions); + const managedRoot = path8.resolve( loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() ); return new StashLifecycleImplementation( @@ -10865,19 +10932,25 @@ function lifecycleTarget(args) { ); } const scope = flag(args, "scope"); - if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + if (scope !== void 0 && scope !== "user" && scope !== "workspace") { throw new StashError( "invalid-argument", - "--scope must be user, workspace, or custom.", + "--scope must be user or workspace.", 2 ); } const root = flag(args, "host-root"); + if (root) { + throw new StashError( + "unsupported-host-root", + "Custom host roots are not supported; lifecycle targets use documented user skill directories.", + 2 + ); + } const workspace = flag(args, "workspace"); return { host, ...scope ? { scope } : {}, - ...root ? { root } : {}, ...workspace ? { workspace } : {} }; } @@ -10968,8 +11041,7 @@ Configuration: Lifecycle targeting: --host codex, claude-code, antigravity-ide, or antigravity-cli. - --scope user or custom (default: user; workspace is rejected). - --host-root Explicit discovery root; implies a custom location. + --scope user (default); workspace is rejected in this release. Result pagination never caps the total relevant result set. Lifecycle commands manage only the Stash-owned store and explicitly selected diff --git a/adapters/claude-code/skills/stash/SKILL.md b/adapters/claude-code/skills/stash/SKILL.md index 21ffd94..092d0ed 100644 --- a/adapters/claude-code/skills/stash/SKILL.md +++ b/adapters/claude-code/skills/stash/SKILL.md @@ -115,17 +115,20 @@ only the temporary staging directory after a successful managed import. ### Archive a standalone skill -Resolve exactly one standalone skill directory and require its host root: +Resolve exactly one standalone skill directory under the host's documented +user skill root: ```text node archive --host [--scope user] --json ``` -An explicit directory path still requires `--host`; use `--scope custom ---host-root ` when selecting a non-default discovery root. The source -must be an exact child of that root. Explain that archive removes the source -only after a journaled copy, validation, hash check, and commit. Never archive -a plugin-contained skill; delegate plugin lifecycle to the host. +The source must be an exact child of the documented user root. Arbitrary custom +roots and workspace roots are unsupported because Stash cannot prove that the +host discovers them. Explain that archive removes the source only after a +journaled copy, validation, hash check, and commit. Never archive a +plugin-contained skill; delegate plugin lifecycle to the host. If the exact +path is already a verified Stash-owned deployment, archive must use tracked +deactivation semantics and preserve the canonical copy. ### Deploy or withdraw a managed copy diff --git a/adapters/claude-code/skills/stash/scripts/stash.mjs b/adapters/claude-code/skills/stash/scripts/stash.mjs index c1d2319..dc6f863 100644 --- a/adapters/claude-code/skills/stash/scripts/stash.mjs +++ b/adapters/claude-code/skills/stash/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path7) { - const ctrl = callVisitor(key, node, visitor, path7); + function visit_(key, node, visitor, path9) { + const ctrl = callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visit_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visit_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path7); + const ci = visit_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = visit_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = visit_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path7); + const cv = visit_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path7) { - const ctrl = await callVisitor(key, node, visitor, path7); + async function visitAsync_(key, node, visitor, path9) { + const ctrl = await callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visitAsync_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visitAsync_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path7); + const ci = await visitAsync_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path7); + const cv = await visitAsync_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path7) { + function callVisitor(key, node, visitor, path9) { if (typeof visitor === "function") - return visitor(key, node, path7); + return visitor(key, node, path9); if (identity.isMap(node)) - return visitor.Map?.(key, node, path7); + return visitor.Map?.(key, node, path9); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path7); + return visitor.Seq?.(key, node, path9); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path7); + return visitor.Pair?.(key, node, path9); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path7); + return visitor.Scalar?.(key, node, path9); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path7); + return visitor.Alias?.(key, node, path9); return void 0; } - function replaceNode(key, path7, node) { - const parent = path7[path7.length - 1]; + function replaceNode(key, path9, node) { + const parent = path9[path9.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path7, value) { + function collectionFromPath(schema, path9, value) { let v = value; - for (let i = path7.length - 1; i >= 0; --i) { - const k = path7[i]; + for (let i = path9.length - 1; i >= 0; --i) { + const k = path9[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; + var isEmptyPath = (path9) => path9 == null || typeof path9 === "object" && !!path9[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path7, value) { - if (isEmptyPath(path7)) + addIn(path9, value) { + if (isEmptyPath(path9)) this.add(value); else { - const [key, ...rest] = path7; + const [key, ...rest] = path9; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - const [key, ...rest] = path7; + deleteIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - const [key, ...rest] = path7; + getIn(path9, keepScalar) { + const [key, ...rest] = path9; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path7) { - const [key, ...rest] = path7; + hasIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - const [key, ...rest] = path7; + setIn(path9, value) { + const [key, ...rest] = path9; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path7, value) { + addIn(path9, value) { if (assertCollection(this.contents)) - this.contents.addIn(path7, value); + this.contents.addIn(path9, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - if (Collection.isEmptyPath(path7)) { + deleteIn(path9) { + if (Collection.isEmptyPath(path9)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path9) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - if (Collection.isEmptyPath(path7)) + getIn(path9, keepScalar) { + if (Collection.isEmptyPath(path9)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path9, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path7) { - if (Collection.isEmptyPath(path7)) + hasIn(path9) { + if (Collection.isEmptyPath(path9)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path9) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - if (Collection.isEmptyPath(path7)) { + setIn(path9, value) { + if (Collection.isEmptyPath(path9)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path9), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path7, value); + this.contents.setIn(path9, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path7) => { + visit.itemAtPath = (cst, path9) => { let item = cst; - for (const [field, index] of path7) { + for (const [field, index] of path9) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path7) => { - const parent = visit.itemAtPath(cst, path7.slice(0, -1)); - const field = path7[path7.length - 1][0]; + visit.parentCollection = (cst, path9) => { + const parent = visit.itemAtPath(cst, path9.slice(0, -1)); + const field = path9[path9.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path7, item, visitor) { - let ctrl = visitor(item, path7); + function _visit(path9, item, visitor) { + let ctrl = visitor(item, path9); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path9.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path7); + ctrl = ctrl(item, path9); } } - return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path9) : ctrl; } exports.visit = visit; } @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; -import path5 from "node:path"; +import { readFile as readFile5, realpath as realpath4, stat as stat4 } from "node:fs/promises"; +import path6 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile, stat } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7484,6 +7484,10 @@ function isPathInside(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative); } +function pathIdentity(value) { + const normalized = path.resolve(value).normalize("NFKC"); + return platform() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} function normalizeRelativePath(value) { if (!value || path.isAbsolute(value)) { return void 0; @@ -7818,12 +7822,16 @@ async function includeManagedCatalog(catalogs, managedRoot) { 2 ); } - const normalizedManagedRoot = path2.resolve(managedRoot); - if (catalogs.some((catalog) => { - const normalizedCatalogRoot = path2.resolve(catalog.root); - return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; - })) { - return catalogs; + const canonicalManagedRoot = await canonicalPotentialPath(managedRoot); + for (const catalog of catalogs) { + const canonicalCatalogRoot = await canonicalPotentialPath(catalog.root); + if (isPathInside(canonicalCatalogRoot, canonicalManagedRoot) || isPathInside(canonicalManagedRoot, canonicalCatalogRoot)) { + throw new StashError( + "invalid-config", + `Managed root must be separate from external catalog "${catalog.id}": "${managedRoot}" overlaps "${catalog.root}".`, + 2 + ); + } } try { const info = await stat(managedRoot); @@ -7853,6 +7861,28 @@ async function includeManagedCatalog(catalogs, managedRoot) { } ]; } +async function canonicalPotentialPath(input) { + const absolute = path2.resolve(input); + const missingSegments = []; + let candidate = absolute; + while (true) { + try { + const canonical = await realpath(candidate); + return path2.resolve(canonical, ...missingSegments.reverse()); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + const parent = path2.dirname(candidate); + if (parent === candidate) { + return absolute; + } + missingSegments.push(path2.basename(candidate)); + candidate = parent; + } + } +} function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); for (const catalog of catalogs) { @@ -7874,7 +7904,7 @@ import { open, readFile as readFile2, readdir, - realpath, + realpath as realpath2, rename, stat as stat2, unlink, @@ -8054,7 +8084,7 @@ async function discoverCandidates(catalog) { const warnings = []; let root; try { - root = await realpath(path3.resolve(catalog.root)); + root = await realpath2(path3.resolve(catalog.root)); } catch (error) { throw new StashError( "catalog-unavailable", @@ -8077,7 +8107,7 @@ async function discoverCandidates(catalog) { if (depth > maxDepth) { return; } - const canonicalDirectory = await realpath(directory); + const canonicalDirectory = await realpath2(directory); if (visitedDirectories.has(canonicalDirectory)) { return; } @@ -8130,7 +8160,7 @@ async function discoverCandidates(catalog) { }); continue; } - const target = await realpath(entryPath); + const target = await realpath2(entryPath); if (!isPathInside(root, target)) { warnings.push({ code: "symlink-escape", @@ -8356,86 +8386,205 @@ async function acquireLock(lockPath) { } // src/internal/managed-projection.ts +import { + readFile as readFile4, + readdir as readdir3 +} from "node:fs/promises"; +import path5 from "node:path"; + +// src/internal/tree-fingerprint.ts import { lstat, readFile as readFile3, readdir as readdir2, - realpath as realpath2, + realpath as realpath3, stat as stat3 } from "node:fs/promises"; -import { platform as platform2 } from "node:os"; import path4 from "node:path"; var MAX_FILES = 1e4; var MAX_TOTAL_BYTES = 256 * 1024 * 1024; -function pathIdentity(value) { - const normalized = path4.resolve(value).normalize("NFKC"); - return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +var TreeFingerprintError = class extends Error { + failure; + relativePath; + detail; + constructor(failure, message, relativePath, detail) { + super(message); + this.name = "TreeFingerprintError"; + this.failure = failure; + this.relativePath = relativePath; + this.detail = detail; + } +}; +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment) || /[<>:"/\\|?*\u0000-\u001F\u007F]/u.test(segment)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is not portable: "${segment}".`, + segment + ); + } + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is reserved on Windows: "${segment}".`, + segment + ); + } } -function validRecord(value) { +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); +} +async function fingerprintTree(sourceRoot, capturePaths = /* @__PURE__ */ new Set()) { + const rootInput = path4.resolve(sourceRoot); + let rootInfo; + try { + rootInfo = await lstat(rootInput); + } catch (error) { + throw new TreeFingerprintError( + "root-unavailable", + `Tree root is unavailable: "${rootInput}".`, + void 0, + error + ); + } + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new TreeFingerprintError( + "unsafe-root", + `Tree root must be a real directory: "${rootInput}".` + ); + } + const root = await realpath3(rootInput); + const entries = []; + const captured = /* @__PURE__ */ new Map(); + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new TreeFingerprintError( + "path-collision", + `Case-insensitive path collision at "${relativePath}".`, + relativePath + ); + } + pathKeys.add(key); + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new TreeFingerprintError( + "linked-tree", + `Links are not allowed in a skill tree: "${relativePath}".`, + relativePath + ); + } + if (before.isDirectory()) { + const canonical = await realpath3(childPath); + if (!isPathInside(root, canonical)) { + throw new TreeFingerprintError( + "tree-escape", + `Directory escapes the skill root: "${relativePath}".`, + relativePath + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new TreeFingerprintError( + "special-file", + `Only regular files and directories are allowed: "${relativePath}".`, + relativePath + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new TreeFingerprintError( + "tree-too-large", + `Skill exceeds ${MAX_FILES} files or ${MAX_TOTAL_BYTES} bytes.`, + relativePath + ); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new TreeFingerprintError( + "tree-changed", + `Skill changed while it was being read: "${relativePath}".`, + relativePath + ); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + if (capturePaths.has(relativePath)) { + captured.set(relativePath, content); + } + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + captured + }; +} + +// src/internal/managed-projection.ts +function validRecord(value, expectedName) { if (!value || typeof value !== "object") { return false; } const record = value; - return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); + return record.schemaVersion === 1 && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === expectedName && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(record.name) && /^sha256:[0-9a-f]{64}$/u.test(record.treeHash) && record.source !== null && typeof record.source === "object" && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path5.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.url === void 0 || typeof record.source.url === "string") && (record.source.revision === void 0 || typeof record.source.revision === "string") && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => deployment !== null && typeof deployment === "object" && typeof deployment.deploymentId === "string" && deployment.deploymentId.length > 0 && deployment.skillId === record.skillId && (deployment.host === "codex" || deployment.host === "claude-code" || deployment.host === "antigravity-ide") && (deployment.scope === "user" || deployment.scope === "workspace") && deployment.method === "copy" && deployment.ownership === "stash" && typeof deployment.root === "string" && path5.isAbsolute(deployment.root) && typeof deployment.path === "string" && path5.isAbsolute(deployment.path) && pathIdentity(deployment.path) === pathIdentity(path5.join(deployment.root, record.name)) && deployment.targetId === `${deployment.host}:${deployment.scope}:${pathIdentity(deployment.root)}` && /^sha256:[0-9a-f]{64}$/u.test(deployment.treeHash) && typeof deployment.deployedAt === "string" + ); } async function treeHash(rootInput) { try { - const rootInfo = await lstat(rootInput); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - return void 0; - } - const root = await realpath2(rootInput); - const entries = []; - let files = 0; - let totalBytes = 0; - async function walk(directory, relativeDirectory) { - const children = await readdir2(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const childPath = path4.join(directory, child.name); - const before = await lstat(childPath); - if (before.isSymbolicLink()) { - throw new Error("linked tree"); - } - if (before.isDirectory()) { - const canonical = await realpath2(childPath); - if (!isPathInside(root, canonical)) { - throw new Error("tree escape"); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new Error("special file"); - } - files += 1; - totalBytes += before.size; - if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { - throw new Error("tree too large"); - } - const content = await readFile3(childPath); - const after = await stat3(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new Error("tree changed"); - } - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash: sha256(content) - }); - } - } - await walk(root, ""); - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); - return sha256(fingerprint); + return (await fingerprintTree(rootInput)).treeHash; } catch { return void 0; } @@ -8473,9 +8622,9 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { return { indexes, fingerprintPart: "" }; } let recordFiles; - const recordsRoot = path4.join(managedRoot, ".stash", "records"); + const recordsRoot = path5.join(managedRoot, ".stash", "records"); try { - recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + recordFiles = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); } catch { return { indexes, fingerprintPart: "" }; } @@ -8484,9 +8633,10 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { for (const file of recordFiles) { try { const parsed = JSON.parse( - await readFile3(path4.join(recordsRoot, file), "utf8") + await readFile4(path5.join(recordsRoot, file), "utf8") ); - if (!validRecord(parsed)) { + const expectedName = file.slice(0, -".json".length); + if (!validRecord(parsed, expectedName)) { throw new Error("invalid record"); } managedRecords.set(parsed.name, parsed); @@ -8513,24 +8663,32 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { }); } } + const events = []; const canonicalBySkillId = /* @__PURE__ */ new Map(); for (const record of managedIndex.records) { const managedRecord = managedRecords.get(record.name); if (managedRecord) { record.managedSkillId = managedRecord.skillId; record.relatedCopies = []; + record.source = { + ...record.source, + ...managedRecord.source.url ? { url: managedRecord.source.url } : {}, + ...managedRecord.source.revision ? { revision: managedRecord.source.revision } : {} + }; + events.push( + `record:${managedRecord.skillId}:${managedRecord.source.url ?? ""}:${managedRecord.source.revision ?? ""}` + ); canonicalBySkillId.set(managedRecord.skillId, record); } } - const events = []; for (const index of indexes) { if (index.catalogId === "managed") { continue; } const visible = []; for (const record of index.records) { - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); const target = targets.get(pathIdentity(skillRoot)); const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; @@ -9257,10 +9415,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path5.dirname( - path5.resolve(index.root, record.relativeSkillFile) + const skillRoot = path6.dirname( + path6.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path5.resolve(skillRoot, normalizedResource); + const candidatePath = path6.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9272,7 +9430,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath3(candidatePath); + resolvedPath = await realpath4(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9333,7 +9491,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile4(resolvedPath); + const buffer = await readFile5(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9454,45 +9612,67 @@ import { randomUUID } from "node:crypto"; import { lstat as lstat2, mkdir as mkdir2, - readFile as readFile5, - readdir as readdir3, - realpath as realpath4, + readFile as readFile6, + readdir as readdir4, + realpath as realpath5, rename as rename2, rm, - stat as stat5, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises"; -import { homedir as homedir2, platform as platform3 } from "node:os"; -import path6 from "node:path"; +import { homedir as homedir3 } from "node:os"; +import path8 from "node:path"; + +// src/internal/lifecycle-host-policy.ts +import { homedir as homedir2 } from "node:os"; +import path7 from "node:path"; +function resolveLifecycleTarget(target, homeDirectory = homedir2()) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace" || target.workspace) { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + const scope = target.scope ?? "user"; + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".gemini", "config", "skills") + }; + } +} +function lifecycleReloadRequired(host) { + return host !== "claude-code"; +} +function lifecycleRefreshObservation(host) { + return lifecycleReloadRequired(host) ? "restart-required" : "live"; +} + +// src/stash-lifecycle.ts var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -var MAX_FILES2 = 1e4; -var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; var STORE_SCHEMA_VERSION = 1; -var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ - "con", - "prn", - "aux", - "nul", - "com1", - "com2", - "com3", - "com4", - "com5", - "com6", - "com7", - "com8", - "com9", - "lpt1", - "lpt2", - "lpt3", - "lpt4", - "lpt5", - "lpt6", - "lpt7", - "lpt8", - "lpt9" -]); function asObject3(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } @@ -9546,143 +9726,45 @@ function parseSkillMetadata(snapshot) { } }; } -function portablePathKey(relativePath) { - return relativePath.normalize("NFKC").toLocaleLowerCase("und"); -} -function validatePortableSegment(segment) { - if (!segment || /[. ]$/u.test(segment)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot end in a dot or space: "${segment}".`, - 3 - ); - } - const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; - if (WINDOWS_RESERVED_NAMES.has(base)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot use the reserved name "${segment}".`, - 3 - ); - } -} async function snapshotTree(sourceRoot) { - const rootInput = path6.resolve(sourceRoot); - const rootInfo = await lstat2(rootInput).catch((error) => { - throw new StashError( - "skill-unavailable", - `Skill directory is unavailable at "${rootInput}": ${String(error)}`, - 4 - ); - }); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - throw new StashError( - "unsafe-skill-tree", - `Skill root must be a real directory, not a link: "${rootInput}".`, - 3 + let fingerprint; + try { + fingerprint = await fingerprintTree( + sourceRoot, + /* @__PURE__ */ new Set(["SKILL.md", "stash.meta.yaml"]) ); - } - const root = await realpath4(rootInput); - const entries = []; - const pathKeys = /* @__PURE__ */ new Set(); - let fileCount = 0; - let totalBytes = 0; - let skillSource; - let sidecarSource; - async function walk(directory, relativeDirectory) { - const children = await readdir3(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - validatePortableSegment(child.name); - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const key = portablePathKey(relativePath); - if (pathKeys.has(key)) { - throw new StashError( - "unsafe-skill-tree", - `Case-insensitive path collision at "${relativePath}".`, - 3 - ); - } - pathKeys.add(key); - const childPath = path6.join(directory, child.name); - const before = await lstat2(childPath); - if (before.isSymbolicLink()) { - throw new StashError( - "unsafe-skill-tree", - `Symlinks and junctions are not allowed: "${relativePath}".`, - 3 - ); - } - if (before.isDirectory()) { - const canonical = await realpath4(childPath); - if (!isPathInside(root, canonical)) { - throw new StashError( - "unsafe-skill-tree", - `Directory escapes the skill root: "${relativePath}".`, - 3 - ); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new StashError( - "unsafe-skill-tree", - `Only regular files and directories are allowed: "${relativePath}".`, - 3 - ); - } - fileCount += 1; - totalBytes += before.size; - if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { - throw new StashError( - "skill-too-large", - `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, - 3 - ); - } - const content = await readFile5(childPath); - const after = await stat5(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new StashError( - "source-changed", - `Skill changed while it was being read: "${relativePath}".`, - 4 - ); - } - const contentHash = sha256(content); - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash - }); - if (relativePath === "SKILL.md") { - skillSource = content.toString("utf8"); - } else if (relativePath === "stash.meta.yaml") { - sidecarSource = content.toString("utf8"); - } + } catch (error) { + if (!(error instanceof TreeFingerprintError)) { + throw error; } + if (error.failure === "root-unavailable") { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${path8.resolve(sourceRoot)}": ${String(error.detail ?? error.message)}`, + 4 + ); + } + if (error.failure === "tree-too-large") { + throw new StashError("skill-too-large", error.message, 3); + } + if (error.failure === "tree-changed") { + throw new StashError("source-changed", error.message, 4); + } + throw new StashError("unsafe-skill-tree", error.message, 3); } - await walk(root, ""); + const skillSource = fingerprint.captured.get("SKILL.md")?.toString("utf8"); if (skillSource === void 0) { throw new StashError( "invalid-skill", - `Skill root must contain SKILL.md: "${root}".`, + `Skill root must contain SKILL.md: "${fingerprint.root}".`, 3 ); } - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); + const sidecarSource = fingerprint.captured.get("stash.meta.yaml")?.toString("utf8"); return { - root, - treeHash: sha256(fingerprint), - entries, + root: fingerprint.root, + treeHash: fingerprint.treeHash, + entries: fingerprint.entries, skillSource, ...sidecarSource !== void 0 ? { sidecarSource } : {} }; @@ -9690,14 +9772,14 @@ async function snapshotTree(sourceRoot) { async function copySnapshot(snapshot, destination) { await mkdir2(destination, { recursive: false }); for (const entry of snapshot.entries) { - const relative = entry.relativePath.split("/").join(path6.sep); - const target = path6.join(destination, relative); + const relative = entry.relativePath.split("/").join(path8.sep); + const target = path8.join(destination, relative); if (entry.kind === "directory") { await mkdir2(target, { recursive: false }); continue; } - const source = path6.join(snapshot.root, relative); - const content = await readFile5(source); + const source = path8.join(snapshot.root, relative); + const content = await readFile6(source); if (sha256(content) !== entry.contentHash) { throw new StashError( "source-changed", @@ -9727,82 +9809,25 @@ async function pathType(target) { } } function samePath(left, right) { - const normalizedLeft = path6.resolve(left).normalize("NFKC"); - const normalizedRight = path6.resolve(right).normalize("NFKC"); - return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; -} -function pathIdentity2(value) { - const normalized = path6.resolve(value).normalize("NFKC"); - return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; + return pathIdentity(left) === pathIdentity(right); } function targetIdentity(target) { - return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; -} -function resolveHostRoot(target) { - if (target.host === "antigravity-cli") { - throw new StashError( - "unsupported-host-layout", - "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", - 2 - ); - } - if (target.scope === "workspace") { - throw new StashError( - "unsupported-host-scope", - "Workspace lifecycle targets are not supported in this release.", - 2 - ); - } - if (target.root) { - return { - host: target.host, - scope: target.scope ?? "custom", - root: path6.resolve(target.root) - }; - } - const scope = target.scope ?? "user"; - if (scope === "custom") { - throw new StashError( - "invalid-argument", - "A custom lifecycle target requires root.", - 2 - ); - } - switch (target.host) { - case "codex": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".agents", "skills") - }; - case "claude-code": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".claude", "skills") - }; - case "antigravity-ide": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".gemini", "config", "skills") - }; - } + return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } async function isPluginContained(source) { - let current = path6.dirname(source); + let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { const markers = [ - path6.join(current, ".claude-plugin", "plugin.json"), - path6.join(current, ".codex-plugin", "plugin.json"), - path6.join(current, "plugin.json") + path8.join(current, ".claude-plugin", "plugin.json"), + path8.join(current, ".codex-plugin", "plugin.json"), + path8.join(current, "plugin.json") ]; for (const marker of markers) { if (await pathType(marker) !== "missing") { return true; } } - const parent = path6.dirname(current); + const parent = path8.dirname(current); if (parent === current) { break; } @@ -9813,21 +9838,22 @@ async function isPluginContained(source) { var StashLifecycleImplementation = class { #managedRoot; #now; + #lifecycleHome = path8.resolve(homedir3()); constructor(managedRoot, now) { - this.#managedRoot = path6.resolve(managedRoot); + this.#managedRoot = path8.resolve(managedRoot); this.#now = now; } #metadataRoot() { - return path6.join(this.#managedRoot, ".stash"); + return path8.join(this.#managedRoot, ".stash"); } #recordPath(name) { - return path6.join(this.#metadataRoot(), "records", `${name}.json`); + return path8.join(this.#metadataRoot(), "records", `${name}.json`); } async #ensureLayout() { - await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); - const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + await mkdir2(path8.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path8.join(this.#managedRoot, "stash.catalog.yaml"); try { await writeFile2( manifestPath, @@ -9842,7 +9868,7 @@ var StashLifecycleImplementation = class { } } #journalPath(operationId) { - return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + return path8.join(this.#metadataRoot(), "journal", `${operationId}.json`); } async #writeJournal(journal) { const finalPath = this.#journalPath(journal.operationId); @@ -9901,16 +9927,16 @@ var StashLifecycleImplementation = class { "source-tombstoned", "archive-committed" ]); - if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path8.isAbsolute(journal.source) || !path8.isAbsolute(journal.tombstone) || !path8.isAbsolute(journal.managedPath)) { throw new StashError( "invalid-lifecycle-journal", `Invalid or unsafe lifecycle journal "${journalPath}".`, 5 ); } - const expectedManagedPath = path6.join(this.#managedRoot, journal.name); - const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); - if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + const expectedManagedPath = path8.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path8.dirname(path8.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path8.dirname(journal.tombstone), expectedTombstoneParent) || !path8.basename(journal.tombstone).startsWith( `.stash-archive-${journal.name}-` ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { throw new StashError( @@ -9975,13 +10001,13 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); } async #recoverJournals() { - const journalRoot = path6.join(this.#metadataRoot(), "journal"); - const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + const journalRoot = path8.join(this.#metadataRoot(), "journal"); + const files = (await readdir4(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); for (const file of files) { - const journalPath = path6.join(journalRoot, file); + const journalPath = path8.join(journalRoot, file); let journal; try { - journal = JSON.parse(await readFile5(journalPath, "utf8")); + journal = JSON.parse(await readFile6(journalPath, "utf8")); } catch (error) { throw new StashError( "invalid-lifecycle-journal", @@ -9997,7 +10023,7 @@ var StashLifecycleImplementation = class { let owner; try { owner = JSON.parse( - await readFile5(path6.join(lockPath, "owner.json"), "utf8") + await readFile6(path8.join(lockPath, "owner.json"), "utf8") ); } catch (error) { throw new StashError( @@ -10015,23 +10041,24 @@ var StashLifecycleImplementation = class { } return owner; } - #ownerIsAlive(owner) { + #ownerState(owner) { try { process.kill(owner.pid, 0); - return true; - } catch { - return false; + return "alive"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + return code === "ESRCH" ? "dead" : "unknown"; } } async #publishLock(lockPath, owner) { - const temporaryPath = path6.join( + const temporaryPath = path8.join( this.#metadataRoot(), `.lifecycle-lock-${owner.ownerToken}.tmp` ); await mkdir2(temporaryPath, { recursive: false }); try { await writeFile2( - path6.join(temporaryPath, "owner.json"), + path8.join(temporaryPath, "owner.json"), `${JSON.stringify(owner)} `, { encoding: "utf8", flag: "wx" } @@ -10052,7 +10079,7 @@ var StashLifecycleImplementation = class { } } async #reclaimDeadLock(lockPath) { - const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + const guardPath = path8.join(this.#metadataRoot(), "lifecycle.reclaim"); try { await mkdir2(guardPath, { recursive: false }); } catch (error) { @@ -10077,14 +10104,14 @@ var StashLifecycleImplementation = class { ); } const owner = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(owner)) { + if (this.#ownerState(owner) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } - const reclaimedPath = path6.join( + const reclaimedPath = path8.join( this.#metadataRoot(), `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` ); @@ -10116,10 +10143,10 @@ var StashLifecycleImplementation = class { return owner; } const existing = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(existing)) { + if (this.#ownerState(existing) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } @@ -10144,7 +10171,7 @@ var StashLifecycleImplementation = class { } async #withLock(operation) { await this.#ensureLayout(); - const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const lockPath = path8.join(this.#metadataRoot(), "lifecycle.lock"); const owner = await this.#acquireLock(lockPath); try { await this.#recoverJournals(); @@ -10156,9 +10183,9 @@ var StashLifecycleImplementation = class { async #readRecord(name) { const recordPath = this.#recordPath(name); try { - const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + const parsed = JSON.parse(await readFile6(recordPath, "utf8")); if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) )) { throw new Error("invalid lifecycle record shape"); } @@ -10191,8 +10218,8 @@ var StashLifecycleImplementation = class { } } async #assertSourceBoundary(source) { - const canonicalSource = await realpath4(source); - const canonicalManaged = await realpath4(this.#managedRoot); + const canonicalSource = await realpath5(source); + const canonicalManaged = await realpath5(this.#managedRoot); if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { throw new StashError( "unsafe-source", @@ -10208,7 +10235,7 @@ var StashLifecycleImplementation = class { type = await pathType(root); } if (type === "missing" && allowMissing) { - return path6.resolve(root); + return path8.resolve(root); } if (type !== "directory") { throw new StashError( @@ -10217,7 +10244,7 @@ var StashLifecycleImplementation = class { 3 ); } - return realpath4(root); + return realpath5(root); } async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { await this.#assertSourceBoundary(source); @@ -10230,7 +10257,7 @@ var StashLifecycleImplementation = class { ); } const metadata = parseSkillMetadata(snapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedPath = path8.join(this.#managedRoot, metadata.name); const existingType = await pathType(managedPath); if (existingType !== "missing") { if (existingType !== "directory") { @@ -10262,7 +10289,7 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + const stage = path8.join(this.#metadataRoot(), "staging", randomUUID()); let committed = false; try { await copySnapshot(snapshot, stage); @@ -10311,7 +10338,7 @@ var StashLifecycleImplementation = class { async install(request) { return this.#withLock(async () => { const stored = await this.#storeSource( - path6.resolve(request.source), + path8.resolve(request.source), "local-import", request.sourceUrl, request.revision @@ -10327,16 +10354,19 @@ var StashLifecycleImplementation = class { } async archive(request) { return this.#withLock(async () => { - const resolvedTarget = resolveHostRoot(request.target); + const resolvedTarget = resolveLifecycleTarget( + request.target, + this.#lifecycleHome + ); resolvedTarget.root = await this.#canonicalHostRoot( resolvedTarget.root, false ); const looksLikeName = NAME_PATTERN2.test(request.source); - const source = path6.resolve( - looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + const source = path8.resolve( + looksLikeName ? path8.join(resolvedTarget.root, request.source) : request.source ); - if (!samePath(path6.dirname(source), resolvedTarget.root)) { + if (!samePath(path8.dirname(source), resolvedTarget.root)) { throw new StashError( "unsafe-archive-source", `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, @@ -10353,11 +10383,39 @@ var StashLifecycleImplementation = class { await this.#assertSourceBoundary(source); const sourceSnapshot = await snapshotTree(source); const metadata = parseSkillMetadata(sourceSnapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); - const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; - const tombstoneParent = path6.dirname(resolvedTarget.root); + const managedPath = path8.join(this.#managedRoot, metadata.name); + const existingRecord = await this.#readRecord(metadata.name); + const managedType = await pathType(managedPath); + const selectedTargetId = targetIdentity(resolvedTarget); + const trackedDeployment = existingRecord?.deployments.find( + (deployment) => samePath(deployment.path, source) && deployment.targetId === selectedTargetId + ); + if (existingRecord && trackedDeployment) { + if (managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${metadata.name}" is unavailable; refusing to archive its tracked deployment.`, + 3 + ); + } + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== existingRecord.treeHash || sourceSnapshot.treeHash !== existingRecord.treeHash || trackedDeployment.treeHash !== existingRecord.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill or tracked deployment "${metadata.name}" drifted; refusing archive.`, + 3 + ); + } + return this.#deactivateDeployment( + existingRecord, + managedPath, + resolvedTarget + ); + } + const managedExistedBefore = managedType !== "missing" || existingRecord !== void 0; + const tombstoneParent = path8.dirname(resolvedTarget.root); await mkdir2(tombstoneParent, { recursive: true }); - const tombstone = path6.join( + const tombstone = path8.join( tombstoneParent, `.stash-archive-${metadata.name}-${randomUUID()}` ); @@ -10444,7 +10502,7 @@ var StashLifecycleImplementation = class { 4 ); } - const managedPath = path6.join(this.#managedRoot, request.name); + const managedPath = path8.join(this.#managedRoot, request.name); const managedSnapshot = await snapshotTree(managedPath); if (managedSnapshot.treeHash !== record.treeHash) { throw new StashError( @@ -10453,9 +10511,9 @@ var StashLifecycleImplementation = class { 3 ); } - const target = resolveHostRoot(request.target); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); target.root = await this.#canonicalHostRoot(target.root, true); - const deploymentPath = path6.join(target.root, request.name); + const deploymentPath = path8.join(target.root, request.name); const targetId = targetIdentity(target); const existing = await pathType(deploymentPath); const tracked = record.deployments.find( @@ -10472,7 +10530,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment: tracked, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } @@ -10483,8 +10541,8 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join( - path6.dirname(target.root), + const stage = path8.join( + path8.dirname(target.root), `.stash-deploy-${record.name}-${randomUUID()}` ); let committed = false; @@ -10535,7 +10593,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } finally { @@ -10545,120 +10603,123 @@ var StashLifecycleImplementation = class { } }); } - async deactivate(request) { - return this.#withLock(async () => { - const record = await this.#readRecord(request.name); - if (!record) { - throw new StashError( - "managed-skill-not-found", - `Managed skill "${request.name}" was not found.`, - 4 - ); - } - const managedPath = path6.join(this.#managedRoot, request.name); - const target = resolveHostRoot(request.target); - target.root = await this.#canonicalHostRoot(target.root, false, true); - const deploymentPath = path6.join(target.root, request.name); - const targetId = targetIdentity(target); - const deployment = record.deployments.find( - (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + async #deactivateDeployment(record, managedPath, target) { + const deploymentPath = path8.join(target.root, record.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 ); - if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { - throw new StashError( - "detached-deployment", - `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, - 3 - ); - } - const existing = await pathType(deploymentPath); - if (existing === "missing") { - record.deployments = record.deployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - await this.#writeRecord(record); - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - warning: "The tracked deployment was already missing." - }; - } - if (existing !== "directory") { - throw new StashError( - "deployment-drift", - `Tracked deployment is no longer a real directory: "${deploymentPath}".`, - 3 - ); - } - const deployedSnapshot = await snapshotTree(deploymentPath); - if (deployedSnapshot.treeHash !== deployment.treeHash) { + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path8.join( + path8.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); throw new StashError( "deployment-drift", - `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, 3 ); } - const tombstone = path6.join( - path6.dirname(target.root), - `.stash-deactivate-${record.name}-${randomUUID()}` + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) ); - await rename2(deploymentPath, tombstone); + record.lastValidatedAt = new Date(this.#now()).toISOString(); try { - const movedSnapshot = await snapshotTree(tombstone); - if (movedSnapshot.treeHash !== deployment.treeHash) { - await rename2(tombstone, deploymentPath); - throw new StashError( - "deployment-drift", - `Deployment changed during deactivation and was restored: "${deploymentPath}".`, - 3 - ); - } - const previousDeployments = record.deployments; - record.deployments = previousDeployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - try { - await this.#writeRecord(record); - } catch (error) { - record.deployments = previousDeployments; - await rename2(tombstone, deploymentPath).catch(() => void 0); - throw error; - } - let warning; - try { - await rm(tombstone, { recursive: true, force: false }); - } catch (error) { - warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; - } - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - reloadRequired: target.host !== "claude-code", - ...warning ? { warning } : {} - }; + await this.#writeRecord(record); } catch (error) { - if (await pathType(tombstone) !== "missing") { - await rename2(tombstone, deploymentPath).catch(() => void 0); - } + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); throw error; } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: lifecycleReloadRequired(target.host), + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path8.join(this.#managedRoot, request.name); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); + target.root = await this.#canonicalHostRoot(target.root, false, true); + return this.#deactivateDeployment(record, managedPath, target); }); } async status(request = {}) { - const recordsRoot = path6.join(this.#metadataRoot(), "records"); + const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; if (request.name) { names = [request.name]; } else { try { - names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + names = (await readdir4(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); } catch (error) { const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; if (code === "ENOENT") { @@ -10674,7 +10735,7 @@ var StashLifecycleImplementation = class { if (!record) { continue; } - const managedPath = path6.join(this.#managedRoot, name); + const managedPath = path8.join(this.#managedRoot, name); const managedType = await pathType(managedPath); let storeState = managedType === "missing" ? "missing" : "stored"; let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; @@ -10698,7 +10759,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "absent", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10711,7 +10772,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10726,7 +10787,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "present", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } catch { @@ -10737,7 +10798,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } @@ -10764,8 +10825,14 @@ var StashLifecycleImplementation = class { } }; async function createStashLifecycle(options = {}) { - const loaded = await loadConfiguration(options); - const managedRoot = path6.resolve( + const effectiveOptions = options.catalogs && !options.managedRoot ? { + ...options, + managedRoot: path8.resolve( + process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ) + } : options; + const loaded = await loadConfiguration(effectiveOptions); + const managedRoot = path8.resolve( loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() ); return new StashLifecycleImplementation( @@ -10865,19 +10932,25 @@ function lifecycleTarget(args) { ); } const scope = flag(args, "scope"); - if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + if (scope !== void 0 && scope !== "user" && scope !== "workspace") { throw new StashError( "invalid-argument", - "--scope must be user, workspace, or custom.", + "--scope must be user or workspace.", 2 ); } const root = flag(args, "host-root"); + if (root) { + throw new StashError( + "unsupported-host-root", + "Custom host roots are not supported; lifecycle targets use documented user skill directories.", + 2 + ); + } const workspace = flag(args, "workspace"); return { host, ...scope ? { scope } : {}, - ...root ? { root } : {}, ...workspace ? { workspace } : {} }; } @@ -10968,8 +11041,7 @@ Configuration: Lifecycle targeting: --host codex, claude-code, antigravity-ide, or antigravity-cli. - --scope user or custom (default: user; workspace is rejected). - --host-root Explicit discovery root; implies a custom location. + --scope user (default); workspace is rejected in this release. Result pagination never caps the total relevant result set. Lifecycle commands manage only the Stash-owned store and explicitly selected diff --git a/adapters/codex/skills/stash/SKILL.md b/adapters/codex/skills/stash/SKILL.md index be8a403..ce33fb8 100644 --- a/adapters/codex/skills/stash/SKILL.md +++ b/adapters/codex/skills/stash/SKILL.md @@ -114,17 +114,20 @@ only the temporary staging directory after a successful managed import. ### Archive a standalone skill -Resolve exactly one standalone skill directory and require its host root: +Resolve exactly one standalone skill directory under the host's documented +user skill root: ```text node archive --host [--scope user] --json ``` -An explicit directory path still requires `--host`; use `--scope custom ---host-root ` when selecting a non-default discovery root. The source -must be an exact child of that root. Explain that archive removes the source -only after a journaled copy, validation, hash check, and commit. Never archive -a plugin-contained skill; delegate plugin lifecycle to the host. +The source must be an exact child of the documented user root. Arbitrary custom +roots and workspace roots are unsupported because Stash cannot prove that the +host discovers them. Explain that archive removes the source only after a +journaled copy, validation, hash check, and commit. Never archive a +plugin-contained skill; delegate plugin lifecycle to the host. If the exact +path is already a verified Stash-owned deployment, archive must use tracked +deactivation semantics and preserve the canonical copy. ### Deploy or withdraw a managed copy diff --git a/adapters/codex/skills/stash/scripts/stash.mjs b/adapters/codex/skills/stash/scripts/stash.mjs index c1d2319..dc6f863 100644 --- a/adapters/codex/skills/stash/scripts/stash.mjs +++ b/adapters/codex/skills/stash/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path7) { - const ctrl = callVisitor(key, node, visitor, path7); + function visit_(key, node, visitor, path9) { + const ctrl = callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visit_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visit_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path7); + const ci = visit_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = visit_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = visit_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path7); + const cv = visit_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path7) { - const ctrl = await callVisitor(key, node, visitor, path7); + async function visitAsync_(key, node, visitor, path9) { + const ctrl = await callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visitAsync_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visitAsync_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path7); + const ci = await visitAsync_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path7); + const cv = await visitAsync_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path7) { + function callVisitor(key, node, visitor, path9) { if (typeof visitor === "function") - return visitor(key, node, path7); + return visitor(key, node, path9); if (identity.isMap(node)) - return visitor.Map?.(key, node, path7); + return visitor.Map?.(key, node, path9); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path7); + return visitor.Seq?.(key, node, path9); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path7); + return visitor.Pair?.(key, node, path9); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path7); + return visitor.Scalar?.(key, node, path9); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path7); + return visitor.Alias?.(key, node, path9); return void 0; } - function replaceNode(key, path7, node) { - const parent = path7[path7.length - 1]; + function replaceNode(key, path9, node) { + const parent = path9[path9.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path7, value) { + function collectionFromPath(schema, path9, value) { let v = value; - for (let i = path7.length - 1; i >= 0; --i) { - const k = path7[i]; + for (let i = path9.length - 1; i >= 0; --i) { + const k = path9[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; + var isEmptyPath = (path9) => path9 == null || typeof path9 === "object" && !!path9[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path7, value) { - if (isEmptyPath(path7)) + addIn(path9, value) { + if (isEmptyPath(path9)) this.add(value); else { - const [key, ...rest] = path7; + const [key, ...rest] = path9; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - const [key, ...rest] = path7; + deleteIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - const [key, ...rest] = path7; + getIn(path9, keepScalar) { + const [key, ...rest] = path9; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path7) { - const [key, ...rest] = path7; + hasIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - const [key, ...rest] = path7; + setIn(path9, value) { + const [key, ...rest] = path9; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path7, value) { + addIn(path9, value) { if (assertCollection(this.contents)) - this.contents.addIn(path7, value); + this.contents.addIn(path9, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - if (Collection.isEmptyPath(path7)) { + deleteIn(path9) { + if (Collection.isEmptyPath(path9)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path9) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - if (Collection.isEmptyPath(path7)) + getIn(path9, keepScalar) { + if (Collection.isEmptyPath(path9)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path9, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path7) { - if (Collection.isEmptyPath(path7)) + hasIn(path9) { + if (Collection.isEmptyPath(path9)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path9) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - if (Collection.isEmptyPath(path7)) { + setIn(path9, value) { + if (Collection.isEmptyPath(path9)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path9), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path7, value); + this.contents.setIn(path9, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path7) => { + visit.itemAtPath = (cst, path9) => { let item = cst; - for (const [field, index] of path7) { + for (const [field, index] of path9) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path7) => { - const parent = visit.itemAtPath(cst, path7.slice(0, -1)); - const field = path7[path7.length - 1][0]; + visit.parentCollection = (cst, path9) => { + const parent = visit.itemAtPath(cst, path9.slice(0, -1)); + const field = path9[path9.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path7, item, visitor) { - let ctrl = visitor(item, path7); + function _visit(path9, item, visitor) { + let ctrl = visitor(item, path9); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path9.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path7); + ctrl = ctrl(item, path9); } } - return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path9) : ctrl; } exports.visit = visit; } @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; -import path5 from "node:path"; +import { readFile as readFile5, realpath as realpath4, stat as stat4 } from "node:fs/promises"; +import path6 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile, stat } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7484,6 +7484,10 @@ function isPathInside(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative); } +function pathIdentity(value) { + const normalized = path.resolve(value).normalize("NFKC"); + return platform() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} function normalizeRelativePath(value) { if (!value || path.isAbsolute(value)) { return void 0; @@ -7818,12 +7822,16 @@ async function includeManagedCatalog(catalogs, managedRoot) { 2 ); } - const normalizedManagedRoot = path2.resolve(managedRoot); - if (catalogs.some((catalog) => { - const normalizedCatalogRoot = path2.resolve(catalog.root); - return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; - })) { - return catalogs; + const canonicalManagedRoot = await canonicalPotentialPath(managedRoot); + for (const catalog of catalogs) { + const canonicalCatalogRoot = await canonicalPotentialPath(catalog.root); + if (isPathInside(canonicalCatalogRoot, canonicalManagedRoot) || isPathInside(canonicalManagedRoot, canonicalCatalogRoot)) { + throw new StashError( + "invalid-config", + `Managed root must be separate from external catalog "${catalog.id}": "${managedRoot}" overlaps "${catalog.root}".`, + 2 + ); + } } try { const info = await stat(managedRoot); @@ -7853,6 +7861,28 @@ async function includeManagedCatalog(catalogs, managedRoot) { } ]; } +async function canonicalPotentialPath(input) { + const absolute = path2.resolve(input); + const missingSegments = []; + let candidate = absolute; + while (true) { + try { + const canonical = await realpath(candidate); + return path2.resolve(canonical, ...missingSegments.reverse()); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + const parent = path2.dirname(candidate); + if (parent === candidate) { + return absolute; + } + missingSegments.push(path2.basename(candidate)); + candidate = parent; + } + } +} function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); for (const catalog of catalogs) { @@ -7874,7 +7904,7 @@ import { open, readFile as readFile2, readdir, - realpath, + realpath as realpath2, rename, stat as stat2, unlink, @@ -8054,7 +8084,7 @@ async function discoverCandidates(catalog) { const warnings = []; let root; try { - root = await realpath(path3.resolve(catalog.root)); + root = await realpath2(path3.resolve(catalog.root)); } catch (error) { throw new StashError( "catalog-unavailable", @@ -8077,7 +8107,7 @@ async function discoverCandidates(catalog) { if (depth > maxDepth) { return; } - const canonicalDirectory = await realpath(directory); + const canonicalDirectory = await realpath2(directory); if (visitedDirectories.has(canonicalDirectory)) { return; } @@ -8130,7 +8160,7 @@ async function discoverCandidates(catalog) { }); continue; } - const target = await realpath(entryPath); + const target = await realpath2(entryPath); if (!isPathInside(root, target)) { warnings.push({ code: "symlink-escape", @@ -8356,86 +8386,205 @@ async function acquireLock(lockPath) { } // src/internal/managed-projection.ts +import { + readFile as readFile4, + readdir as readdir3 +} from "node:fs/promises"; +import path5 from "node:path"; + +// src/internal/tree-fingerprint.ts import { lstat, readFile as readFile3, readdir as readdir2, - realpath as realpath2, + realpath as realpath3, stat as stat3 } from "node:fs/promises"; -import { platform as platform2 } from "node:os"; import path4 from "node:path"; var MAX_FILES = 1e4; var MAX_TOTAL_BYTES = 256 * 1024 * 1024; -function pathIdentity(value) { - const normalized = path4.resolve(value).normalize("NFKC"); - return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +var TreeFingerprintError = class extends Error { + failure; + relativePath; + detail; + constructor(failure, message, relativePath, detail) { + super(message); + this.name = "TreeFingerprintError"; + this.failure = failure; + this.relativePath = relativePath; + this.detail = detail; + } +}; +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment) || /[<>:"/\\|?*\u0000-\u001F\u007F]/u.test(segment)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is not portable: "${segment}".`, + segment + ); + } + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is reserved on Windows: "${segment}".`, + segment + ); + } } -function validRecord(value) { +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); +} +async function fingerprintTree(sourceRoot, capturePaths = /* @__PURE__ */ new Set()) { + const rootInput = path4.resolve(sourceRoot); + let rootInfo; + try { + rootInfo = await lstat(rootInput); + } catch (error) { + throw new TreeFingerprintError( + "root-unavailable", + `Tree root is unavailable: "${rootInput}".`, + void 0, + error + ); + } + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new TreeFingerprintError( + "unsafe-root", + `Tree root must be a real directory: "${rootInput}".` + ); + } + const root = await realpath3(rootInput); + const entries = []; + const captured = /* @__PURE__ */ new Map(); + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new TreeFingerprintError( + "path-collision", + `Case-insensitive path collision at "${relativePath}".`, + relativePath + ); + } + pathKeys.add(key); + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new TreeFingerprintError( + "linked-tree", + `Links are not allowed in a skill tree: "${relativePath}".`, + relativePath + ); + } + if (before.isDirectory()) { + const canonical = await realpath3(childPath); + if (!isPathInside(root, canonical)) { + throw new TreeFingerprintError( + "tree-escape", + `Directory escapes the skill root: "${relativePath}".`, + relativePath + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new TreeFingerprintError( + "special-file", + `Only regular files and directories are allowed: "${relativePath}".`, + relativePath + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new TreeFingerprintError( + "tree-too-large", + `Skill exceeds ${MAX_FILES} files or ${MAX_TOTAL_BYTES} bytes.`, + relativePath + ); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new TreeFingerprintError( + "tree-changed", + `Skill changed while it was being read: "${relativePath}".`, + relativePath + ); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + if (capturePaths.has(relativePath)) { + captured.set(relativePath, content); + } + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + captured + }; +} + +// src/internal/managed-projection.ts +function validRecord(value, expectedName) { if (!value || typeof value !== "object") { return false; } const record = value; - return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); + return record.schemaVersion === 1 && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === expectedName && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(record.name) && /^sha256:[0-9a-f]{64}$/u.test(record.treeHash) && record.source !== null && typeof record.source === "object" && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path5.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.url === void 0 || typeof record.source.url === "string") && (record.source.revision === void 0 || typeof record.source.revision === "string") && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => deployment !== null && typeof deployment === "object" && typeof deployment.deploymentId === "string" && deployment.deploymentId.length > 0 && deployment.skillId === record.skillId && (deployment.host === "codex" || deployment.host === "claude-code" || deployment.host === "antigravity-ide") && (deployment.scope === "user" || deployment.scope === "workspace") && deployment.method === "copy" && deployment.ownership === "stash" && typeof deployment.root === "string" && path5.isAbsolute(deployment.root) && typeof deployment.path === "string" && path5.isAbsolute(deployment.path) && pathIdentity(deployment.path) === pathIdentity(path5.join(deployment.root, record.name)) && deployment.targetId === `${deployment.host}:${deployment.scope}:${pathIdentity(deployment.root)}` && /^sha256:[0-9a-f]{64}$/u.test(deployment.treeHash) && typeof deployment.deployedAt === "string" + ); } async function treeHash(rootInput) { try { - const rootInfo = await lstat(rootInput); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - return void 0; - } - const root = await realpath2(rootInput); - const entries = []; - let files = 0; - let totalBytes = 0; - async function walk(directory, relativeDirectory) { - const children = await readdir2(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const childPath = path4.join(directory, child.name); - const before = await lstat(childPath); - if (before.isSymbolicLink()) { - throw new Error("linked tree"); - } - if (before.isDirectory()) { - const canonical = await realpath2(childPath); - if (!isPathInside(root, canonical)) { - throw new Error("tree escape"); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new Error("special file"); - } - files += 1; - totalBytes += before.size; - if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { - throw new Error("tree too large"); - } - const content = await readFile3(childPath); - const after = await stat3(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new Error("tree changed"); - } - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash: sha256(content) - }); - } - } - await walk(root, ""); - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); - return sha256(fingerprint); + return (await fingerprintTree(rootInput)).treeHash; } catch { return void 0; } @@ -8473,9 +8622,9 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { return { indexes, fingerprintPart: "" }; } let recordFiles; - const recordsRoot = path4.join(managedRoot, ".stash", "records"); + const recordsRoot = path5.join(managedRoot, ".stash", "records"); try { - recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + recordFiles = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); } catch { return { indexes, fingerprintPart: "" }; } @@ -8484,9 +8633,10 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { for (const file of recordFiles) { try { const parsed = JSON.parse( - await readFile3(path4.join(recordsRoot, file), "utf8") + await readFile4(path5.join(recordsRoot, file), "utf8") ); - if (!validRecord(parsed)) { + const expectedName = file.slice(0, -".json".length); + if (!validRecord(parsed, expectedName)) { throw new Error("invalid record"); } managedRecords.set(parsed.name, parsed); @@ -8513,24 +8663,32 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { }); } } + const events = []; const canonicalBySkillId = /* @__PURE__ */ new Map(); for (const record of managedIndex.records) { const managedRecord = managedRecords.get(record.name); if (managedRecord) { record.managedSkillId = managedRecord.skillId; record.relatedCopies = []; + record.source = { + ...record.source, + ...managedRecord.source.url ? { url: managedRecord.source.url } : {}, + ...managedRecord.source.revision ? { revision: managedRecord.source.revision } : {} + }; + events.push( + `record:${managedRecord.skillId}:${managedRecord.source.url ?? ""}:${managedRecord.source.revision ?? ""}` + ); canonicalBySkillId.set(managedRecord.skillId, record); } } - const events = []; for (const index of indexes) { if (index.catalogId === "managed") { continue; } const visible = []; for (const record of index.records) { - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); const target = targets.get(pathIdentity(skillRoot)); const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; @@ -9257,10 +9415,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path5.dirname( - path5.resolve(index.root, record.relativeSkillFile) + const skillRoot = path6.dirname( + path6.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path5.resolve(skillRoot, normalizedResource); + const candidatePath = path6.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9272,7 +9430,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath3(candidatePath); + resolvedPath = await realpath4(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9333,7 +9491,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile4(resolvedPath); + const buffer = await readFile5(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9454,45 +9612,67 @@ import { randomUUID } from "node:crypto"; import { lstat as lstat2, mkdir as mkdir2, - readFile as readFile5, - readdir as readdir3, - realpath as realpath4, + readFile as readFile6, + readdir as readdir4, + realpath as realpath5, rename as rename2, rm, - stat as stat5, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises"; -import { homedir as homedir2, platform as platform3 } from "node:os"; -import path6 from "node:path"; +import { homedir as homedir3 } from "node:os"; +import path8 from "node:path"; + +// src/internal/lifecycle-host-policy.ts +import { homedir as homedir2 } from "node:os"; +import path7 from "node:path"; +function resolveLifecycleTarget(target, homeDirectory = homedir2()) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace" || target.workspace) { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + const scope = target.scope ?? "user"; + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".gemini", "config", "skills") + }; + } +} +function lifecycleReloadRequired(host) { + return host !== "claude-code"; +} +function lifecycleRefreshObservation(host) { + return lifecycleReloadRequired(host) ? "restart-required" : "live"; +} + +// src/stash-lifecycle.ts var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -var MAX_FILES2 = 1e4; -var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; var STORE_SCHEMA_VERSION = 1; -var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ - "con", - "prn", - "aux", - "nul", - "com1", - "com2", - "com3", - "com4", - "com5", - "com6", - "com7", - "com8", - "com9", - "lpt1", - "lpt2", - "lpt3", - "lpt4", - "lpt5", - "lpt6", - "lpt7", - "lpt8", - "lpt9" -]); function asObject3(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } @@ -9546,143 +9726,45 @@ function parseSkillMetadata(snapshot) { } }; } -function portablePathKey(relativePath) { - return relativePath.normalize("NFKC").toLocaleLowerCase("und"); -} -function validatePortableSegment(segment) { - if (!segment || /[. ]$/u.test(segment)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot end in a dot or space: "${segment}".`, - 3 - ); - } - const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; - if (WINDOWS_RESERVED_NAMES.has(base)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot use the reserved name "${segment}".`, - 3 - ); - } -} async function snapshotTree(sourceRoot) { - const rootInput = path6.resolve(sourceRoot); - const rootInfo = await lstat2(rootInput).catch((error) => { - throw new StashError( - "skill-unavailable", - `Skill directory is unavailable at "${rootInput}": ${String(error)}`, - 4 - ); - }); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - throw new StashError( - "unsafe-skill-tree", - `Skill root must be a real directory, not a link: "${rootInput}".`, - 3 + let fingerprint; + try { + fingerprint = await fingerprintTree( + sourceRoot, + /* @__PURE__ */ new Set(["SKILL.md", "stash.meta.yaml"]) ); - } - const root = await realpath4(rootInput); - const entries = []; - const pathKeys = /* @__PURE__ */ new Set(); - let fileCount = 0; - let totalBytes = 0; - let skillSource; - let sidecarSource; - async function walk(directory, relativeDirectory) { - const children = await readdir3(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - validatePortableSegment(child.name); - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const key = portablePathKey(relativePath); - if (pathKeys.has(key)) { - throw new StashError( - "unsafe-skill-tree", - `Case-insensitive path collision at "${relativePath}".`, - 3 - ); - } - pathKeys.add(key); - const childPath = path6.join(directory, child.name); - const before = await lstat2(childPath); - if (before.isSymbolicLink()) { - throw new StashError( - "unsafe-skill-tree", - `Symlinks and junctions are not allowed: "${relativePath}".`, - 3 - ); - } - if (before.isDirectory()) { - const canonical = await realpath4(childPath); - if (!isPathInside(root, canonical)) { - throw new StashError( - "unsafe-skill-tree", - `Directory escapes the skill root: "${relativePath}".`, - 3 - ); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new StashError( - "unsafe-skill-tree", - `Only regular files and directories are allowed: "${relativePath}".`, - 3 - ); - } - fileCount += 1; - totalBytes += before.size; - if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { - throw new StashError( - "skill-too-large", - `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, - 3 - ); - } - const content = await readFile5(childPath); - const after = await stat5(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new StashError( - "source-changed", - `Skill changed while it was being read: "${relativePath}".`, - 4 - ); - } - const contentHash = sha256(content); - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash - }); - if (relativePath === "SKILL.md") { - skillSource = content.toString("utf8"); - } else if (relativePath === "stash.meta.yaml") { - sidecarSource = content.toString("utf8"); - } + } catch (error) { + if (!(error instanceof TreeFingerprintError)) { + throw error; } + if (error.failure === "root-unavailable") { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${path8.resolve(sourceRoot)}": ${String(error.detail ?? error.message)}`, + 4 + ); + } + if (error.failure === "tree-too-large") { + throw new StashError("skill-too-large", error.message, 3); + } + if (error.failure === "tree-changed") { + throw new StashError("source-changed", error.message, 4); + } + throw new StashError("unsafe-skill-tree", error.message, 3); } - await walk(root, ""); + const skillSource = fingerprint.captured.get("SKILL.md")?.toString("utf8"); if (skillSource === void 0) { throw new StashError( "invalid-skill", - `Skill root must contain SKILL.md: "${root}".`, + `Skill root must contain SKILL.md: "${fingerprint.root}".`, 3 ); } - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); + const sidecarSource = fingerprint.captured.get("stash.meta.yaml")?.toString("utf8"); return { - root, - treeHash: sha256(fingerprint), - entries, + root: fingerprint.root, + treeHash: fingerprint.treeHash, + entries: fingerprint.entries, skillSource, ...sidecarSource !== void 0 ? { sidecarSource } : {} }; @@ -9690,14 +9772,14 @@ async function snapshotTree(sourceRoot) { async function copySnapshot(snapshot, destination) { await mkdir2(destination, { recursive: false }); for (const entry of snapshot.entries) { - const relative = entry.relativePath.split("/").join(path6.sep); - const target = path6.join(destination, relative); + const relative = entry.relativePath.split("/").join(path8.sep); + const target = path8.join(destination, relative); if (entry.kind === "directory") { await mkdir2(target, { recursive: false }); continue; } - const source = path6.join(snapshot.root, relative); - const content = await readFile5(source); + const source = path8.join(snapshot.root, relative); + const content = await readFile6(source); if (sha256(content) !== entry.contentHash) { throw new StashError( "source-changed", @@ -9727,82 +9809,25 @@ async function pathType(target) { } } function samePath(left, right) { - const normalizedLeft = path6.resolve(left).normalize("NFKC"); - const normalizedRight = path6.resolve(right).normalize("NFKC"); - return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; -} -function pathIdentity2(value) { - const normalized = path6.resolve(value).normalize("NFKC"); - return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; + return pathIdentity(left) === pathIdentity(right); } function targetIdentity(target) { - return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; -} -function resolveHostRoot(target) { - if (target.host === "antigravity-cli") { - throw new StashError( - "unsupported-host-layout", - "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", - 2 - ); - } - if (target.scope === "workspace") { - throw new StashError( - "unsupported-host-scope", - "Workspace lifecycle targets are not supported in this release.", - 2 - ); - } - if (target.root) { - return { - host: target.host, - scope: target.scope ?? "custom", - root: path6.resolve(target.root) - }; - } - const scope = target.scope ?? "user"; - if (scope === "custom") { - throw new StashError( - "invalid-argument", - "A custom lifecycle target requires root.", - 2 - ); - } - switch (target.host) { - case "codex": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".agents", "skills") - }; - case "claude-code": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".claude", "skills") - }; - case "antigravity-ide": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".gemini", "config", "skills") - }; - } + return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } async function isPluginContained(source) { - let current = path6.dirname(source); + let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { const markers = [ - path6.join(current, ".claude-plugin", "plugin.json"), - path6.join(current, ".codex-plugin", "plugin.json"), - path6.join(current, "plugin.json") + path8.join(current, ".claude-plugin", "plugin.json"), + path8.join(current, ".codex-plugin", "plugin.json"), + path8.join(current, "plugin.json") ]; for (const marker of markers) { if (await pathType(marker) !== "missing") { return true; } } - const parent = path6.dirname(current); + const parent = path8.dirname(current); if (parent === current) { break; } @@ -9813,21 +9838,22 @@ async function isPluginContained(source) { var StashLifecycleImplementation = class { #managedRoot; #now; + #lifecycleHome = path8.resolve(homedir3()); constructor(managedRoot, now) { - this.#managedRoot = path6.resolve(managedRoot); + this.#managedRoot = path8.resolve(managedRoot); this.#now = now; } #metadataRoot() { - return path6.join(this.#managedRoot, ".stash"); + return path8.join(this.#managedRoot, ".stash"); } #recordPath(name) { - return path6.join(this.#metadataRoot(), "records", `${name}.json`); + return path8.join(this.#metadataRoot(), "records", `${name}.json`); } async #ensureLayout() { - await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); - const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + await mkdir2(path8.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path8.join(this.#managedRoot, "stash.catalog.yaml"); try { await writeFile2( manifestPath, @@ -9842,7 +9868,7 @@ var StashLifecycleImplementation = class { } } #journalPath(operationId) { - return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + return path8.join(this.#metadataRoot(), "journal", `${operationId}.json`); } async #writeJournal(journal) { const finalPath = this.#journalPath(journal.operationId); @@ -9901,16 +9927,16 @@ var StashLifecycleImplementation = class { "source-tombstoned", "archive-committed" ]); - if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path8.isAbsolute(journal.source) || !path8.isAbsolute(journal.tombstone) || !path8.isAbsolute(journal.managedPath)) { throw new StashError( "invalid-lifecycle-journal", `Invalid or unsafe lifecycle journal "${journalPath}".`, 5 ); } - const expectedManagedPath = path6.join(this.#managedRoot, journal.name); - const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); - if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + const expectedManagedPath = path8.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path8.dirname(path8.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path8.dirname(journal.tombstone), expectedTombstoneParent) || !path8.basename(journal.tombstone).startsWith( `.stash-archive-${journal.name}-` ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { throw new StashError( @@ -9975,13 +10001,13 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); } async #recoverJournals() { - const journalRoot = path6.join(this.#metadataRoot(), "journal"); - const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + const journalRoot = path8.join(this.#metadataRoot(), "journal"); + const files = (await readdir4(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); for (const file of files) { - const journalPath = path6.join(journalRoot, file); + const journalPath = path8.join(journalRoot, file); let journal; try { - journal = JSON.parse(await readFile5(journalPath, "utf8")); + journal = JSON.parse(await readFile6(journalPath, "utf8")); } catch (error) { throw new StashError( "invalid-lifecycle-journal", @@ -9997,7 +10023,7 @@ var StashLifecycleImplementation = class { let owner; try { owner = JSON.parse( - await readFile5(path6.join(lockPath, "owner.json"), "utf8") + await readFile6(path8.join(lockPath, "owner.json"), "utf8") ); } catch (error) { throw new StashError( @@ -10015,23 +10041,24 @@ var StashLifecycleImplementation = class { } return owner; } - #ownerIsAlive(owner) { + #ownerState(owner) { try { process.kill(owner.pid, 0); - return true; - } catch { - return false; + return "alive"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + return code === "ESRCH" ? "dead" : "unknown"; } } async #publishLock(lockPath, owner) { - const temporaryPath = path6.join( + const temporaryPath = path8.join( this.#metadataRoot(), `.lifecycle-lock-${owner.ownerToken}.tmp` ); await mkdir2(temporaryPath, { recursive: false }); try { await writeFile2( - path6.join(temporaryPath, "owner.json"), + path8.join(temporaryPath, "owner.json"), `${JSON.stringify(owner)} `, { encoding: "utf8", flag: "wx" } @@ -10052,7 +10079,7 @@ var StashLifecycleImplementation = class { } } async #reclaimDeadLock(lockPath) { - const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + const guardPath = path8.join(this.#metadataRoot(), "lifecycle.reclaim"); try { await mkdir2(guardPath, { recursive: false }); } catch (error) { @@ -10077,14 +10104,14 @@ var StashLifecycleImplementation = class { ); } const owner = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(owner)) { + if (this.#ownerState(owner) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } - const reclaimedPath = path6.join( + const reclaimedPath = path8.join( this.#metadataRoot(), `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` ); @@ -10116,10 +10143,10 @@ var StashLifecycleImplementation = class { return owner; } const existing = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(existing)) { + if (this.#ownerState(existing) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } @@ -10144,7 +10171,7 @@ var StashLifecycleImplementation = class { } async #withLock(operation) { await this.#ensureLayout(); - const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const lockPath = path8.join(this.#metadataRoot(), "lifecycle.lock"); const owner = await this.#acquireLock(lockPath); try { await this.#recoverJournals(); @@ -10156,9 +10183,9 @@ var StashLifecycleImplementation = class { async #readRecord(name) { const recordPath = this.#recordPath(name); try { - const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + const parsed = JSON.parse(await readFile6(recordPath, "utf8")); if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) )) { throw new Error("invalid lifecycle record shape"); } @@ -10191,8 +10218,8 @@ var StashLifecycleImplementation = class { } } async #assertSourceBoundary(source) { - const canonicalSource = await realpath4(source); - const canonicalManaged = await realpath4(this.#managedRoot); + const canonicalSource = await realpath5(source); + const canonicalManaged = await realpath5(this.#managedRoot); if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { throw new StashError( "unsafe-source", @@ -10208,7 +10235,7 @@ var StashLifecycleImplementation = class { type = await pathType(root); } if (type === "missing" && allowMissing) { - return path6.resolve(root); + return path8.resolve(root); } if (type !== "directory") { throw new StashError( @@ -10217,7 +10244,7 @@ var StashLifecycleImplementation = class { 3 ); } - return realpath4(root); + return realpath5(root); } async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { await this.#assertSourceBoundary(source); @@ -10230,7 +10257,7 @@ var StashLifecycleImplementation = class { ); } const metadata = parseSkillMetadata(snapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedPath = path8.join(this.#managedRoot, metadata.name); const existingType = await pathType(managedPath); if (existingType !== "missing") { if (existingType !== "directory") { @@ -10262,7 +10289,7 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + const stage = path8.join(this.#metadataRoot(), "staging", randomUUID()); let committed = false; try { await copySnapshot(snapshot, stage); @@ -10311,7 +10338,7 @@ var StashLifecycleImplementation = class { async install(request) { return this.#withLock(async () => { const stored = await this.#storeSource( - path6.resolve(request.source), + path8.resolve(request.source), "local-import", request.sourceUrl, request.revision @@ -10327,16 +10354,19 @@ var StashLifecycleImplementation = class { } async archive(request) { return this.#withLock(async () => { - const resolvedTarget = resolveHostRoot(request.target); + const resolvedTarget = resolveLifecycleTarget( + request.target, + this.#lifecycleHome + ); resolvedTarget.root = await this.#canonicalHostRoot( resolvedTarget.root, false ); const looksLikeName = NAME_PATTERN2.test(request.source); - const source = path6.resolve( - looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + const source = path8.resolve( + looksLikeName ? path8.join(resolvedTarget.root, request.source) : request.source ); - if (!samePath(path6.dirname(source), resolvedTarget.root)) { + if (!samePath(path8.dirname(source), resolvedTarget.root)) { throw new StashError( "unsafe-archive-source", `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, @@ -10353,11 +10383,39 @@ var StashLifecycleImplementation = class { await this.#assertSourceBoundary(source); const sourceSnapshot = await snapshotTree(source); const metadata = parseSkillMetadata(sourceSnapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); - const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; - const tombstoneParent = path6.dirname(resolvedTarget.root); + const managedPath = path8.join(this.#managedRoot, metadata.name); + const existingRecord = await this.#readRecord(metadata.name); + const managedType = await pathType(managedPath); + const selectedTargetId = targetIdentity(resolvedTarget); + const trackedDeployment = existingRecord?.deployments.find( + (deployment) => samePath(deployment.path, source) && deployment.targetId === selectedTargetId + ); + if (existingRecord && trackedDeployment) { + if (managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${metadata.name}" is unavailable; refusing to archive its tracked deployment.`, + 3 + ); + } + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== existingRecord.treeHash || sourceSnapshot.treeHash !== existingRecord.treeHash || trackedDeployment.treeHash !== existingRecord.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill or tracked deployment "${metadata.name}" drifted; refusing archive.`, + 3 + ); + } + return this.#deactivateDeployment( + existingRecord, + managedPath, + resolvedTarget + ); + } + const managedExistedBefore = managedType !== "missing" || existingRecord !== void 0; + const tombstoneParent = path8.dirname(resolvedTarget.root); await mkdir2(tombstoneParent, { recursive: true }); - const tombstone = path6.join( + const tombstone = path8.join( tombstoneParent, `.stash-archive-${metadata.name}-${randomUUID()}` ); @@ -10444,7 +10502,7 @@ var StashLifecycleImplementation = class { 4 ); } - const managedPath = path6.join(this.#managedRoot, request.name); + const managedPath = path8.join(this.#managedRoot, request.name); const managedSnapshot = await snapshotTree(managedPath); if (managedSnapshot.treeHash !== record.treeHash) { throw new StashError( @@ -10453,9 +10511,9 @@ var StashLifecycleImplementation = class { 3 ); } - const target = resolveHostRoot(request.target); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); target.root = await this.#canonicalHostRoot(target.root, true); - const deploymentPath = path6.join(target.root, request.name); + const deploymentPath = path8.join(target.root, request.name); const targetId = targetIdentity(target); const existing = await pathType(deploymentPath); const tracked = record.deployments.find( @@ -10472,7 +10530,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment: tracked, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } @@ -10483,8 +10541,8 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join( - path6.dirname(target.root), + const stage = path8.join( + path8.dirname(target.root), `.stash-deploy-${record.name}-${randomUUID()}` ); let committed = false; @@ -10535,7 +10593,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } finally { @@ -10545,120 +10603,123 @@ var StashLifecycleImplementation = class { } }); } - async deactivate(request) { - return this.#withLock(async () => { - const record = await this.#readRecord(request.name); - if (!record) { - throw new StashError( - "managed-skill-not-found", - `Managed skill "${request.name}" was not found.`, - 4 - ); - } - const managedPath = path6.join(this.#managedRoot, request.name); - const target = resolveHostRoot(request.target); - target.root = await this.#canonicalHostRoot(target.root, false, true); - const deploymentPath = path6.join(target.root, request.name); - const targetId = targetIdentity(target); - const deployment = record.deployments.find( - (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + async #deactivateDeployment(record, managedPath, target) { + const deploymentPath = path8.join(target.root, record.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 ); - if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { - throw new StashError( - "detached-deployment", - `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, - 3 - ); - } - const existing = await pathType(deploymentPath); - if (existing === "missing") { - record.deployments = record.deployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - await this.#writeRecord(record); - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - warning: "The tracked deployment was already missing." - }; - } - if (existing !== "directory") { - throw new StashError( - "deployment-drift", - `Tracked deployment is no longer a real directory: "${deploymentPath}".`, - 3 - ); - } - const deployedSnapshot = await snapshotTree(deploymentPath); - if (deployedSnapshot.treeHash !== deployment.treeHash) { + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path8.join( + path8.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); throw new StashError( "deployment-drift", - `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, 3 ); } - const tombstone = path6.join( - path6.dirname(target.root), - `.stash-deactivate-${record.name}-${randomUUID()}` + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) ); - await rename2(deploymentPath, tombstone); + record.lastValidatedAt = new Date(this.#now()).toISOString(); try { - const movedSnapshot = await snapshotTree(tombstone); - if (movedSnapshot.treeHash !== deployment.treeHash) { - await rename2(tombstone, deploymentPath); - throw new StashError( - "deployment-drift", - `Deployment changed during deactivation and was restored: "${deploymentPath}".`, - 3 - ); - } - const previousDeployments = record.deployments; - record.deployments = previousDeployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - try { - await this.#writeRecord(record); - } catch (error) { - record.deployments = previousDeployments; - await rename2(tombstone, deploymentPath).catch(() => void 0); - throw error; - } - let warning; - try { - await rm(tombstone, { recursive: true, force: false }); - } catch (error) { - warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; - } - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - reloadRequired: target.host !== "claude-code", - ...warning ? { warning } : {} - }; + await this.#writeRecord(record); } catch (error) { - if (await pathType(tombstone) !== "missing") { - await rename2(tombstone, deploymentPath).catch(() => void 0); - } + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); throw error; } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: lifecycleReloadRequired(target.host), + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path8.join(this.#managedRoot, request.name); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); + target.root = await this.#canonicalHostRoot(target.root, false, true); + return this.#deactivateDeployment(record, managedPath, target); }); } async status(request = {}) { - const recordsRoot = path6.join(this.#metadataRoot(), "records"); + const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; if (request.name) { names = [request.name]; } else { try { - names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + names = (await readdir4(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); } catch (error) { const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; if (code === "ENOENT") { @@ -10674,7 +10735,7 @@ var StashLifecycleImplementation = class { if (!record) { continue; } - const managedPath = path6.join(this.#managedRoot, name); + const managedPath = path8.join(this.#managedRoot, name); const managedType = await pathType(managedPath); let storeState = managedType === "missing" ? "missing" : "stored"; let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; @@ -10698,7 +10759,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "absent", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10711,7 +10772,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10726,7 +10787,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "present", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } catch { @@ -10737,7 +10798,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } @@ -10764,8 +10825,14 @@ var StashLifecycleImplementation = class { } }; async function createStashLifecycle(options = {}) { - const loaded = await loadConfiguration(options); - const managedRoot = path6.resolve( + const effectiveOptions = options.catalogs && !options.managedRoot ? { + ...options, + managedRoot: path8.resolve( + process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ) + } : options; + const loaded = await loadConfiguration(effectiveOptions); + const managedRoot = path8.resolve( loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() ); return new StashLifecycleImplementation( @@ -10865,19 +10932,25 @@ function lifecycleTarget(args) { ); } const scope = flag(args, "scope"); - if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + if (scope !== void 0 && scope !== "user" && scope !== "workspace") { throw new StashError( "invalid-argument", - "--scope must be user, workspace, or custom.", + "--scope must be user or workspace.", 2 ); } const root = flag(args, "host-root"); + if (root) { + throw new StashError( + "unsupported-host-root", + "Custom host roots are not supported; lifecycle targets use documented user skill directories.", + 2 + ); + } const workspace = flag(args, "workspace"); return { host, ...scope ? { scope } : {}, - ...root ? { root } : {}, ...workspace ? { workspace } : {} }; } @@ -10968,8 +11041,7 @@ Configuration: Lifecycle targeting: --host codex, claude-code, antigravity-ide, or antigravity-cli. - --scope user or custom (default: user; workspace is rejected). - --host-root Explicit discovery root; implies a custom location. + --scope user (default); workspace is rejected in this release. Result pagination never caps the total relevant result set. Lifecycle commands manage only the Stash-owned store and explicitly selected diff --git a/docs/architecture.md b/docs/architecture.md index bbf8d55..22d7bba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,9 @@ flowchart TD The product has two deep Modules with different authority. `StashCatalog` is read-only across every configured catalog. `StashLifecycle` alone may write to the Stash-managed root or an explicitly selected standalone deployment target. -It never mutates an external catalog, plugin, or vendor setting. +A target root may also be registered for search, but catalog registration does +not authorize the write; the explicit archive/deactivate request does. Plugin +content and vendor settings remain outside lifecycle authority. The model keeps three independent dimensions: `catalogId` identifies local storage, `group` supplies functional taxonomy, and `source` records provenance. A source filter accepts an exact ID, display name, or URL. Resolve filters compose across those dimensions before exact lookup, listing, or discovery. @@ -74,6 +76,8 @@ src/ └── internal/ ├── configuration.ts ├── catalog-index.ts + ├── lifecycle-host-policy.ts + ├── managed-projection.ts ├── search.ts └── util.ts ``` @@ -82,6 +86,10 @@ Responsibilities: - `configuration.ts`: resolve platform configuration and validate catalogs. - `catalog-index.ts`: canonicalize roots, discover skills, parse metadata, generate records, atomically cache indexes. +- `lifecycle-host-policy.ts`: centralize the small, audited vendor discovery-root + and reload-observation policy used only by explicit lifecycle operations. +- `managed-projection.ts`: fold verified managed-related copies without + changing the underlying indexes or raw-read refs. - `search.ts`: normalize text, score lexical evidence, classify relevance, and render compact records. - `util.ts`: hashing, cursor integrity, path containment, tokenization, platform locations. - `stash-catalog.ts`: orchestrate the Interface and normalize errors/results. @@ -98,7 +106,10 @@ The vendor seam is real because there are multiple implementations: - Claude Code adds `disable-model-invocation: true`. - Antigravity uses different plugin manifests and has no documented manual-only field. -Search and security behavior never live in an Adapter. Generated Adapters contain the same bundled CLI. +Search and safe-read security behavior never live in an Adapter. Generated +Adapters contain the same bundled CLI; vendor packaging stays in Adapter +generation, while the narrow lifecycle target policy stays in one audited +internal module and contract tests. ## Data flow @@ -140,8 +151,10 @@ The index is not a source of truth. - Explicit `stash index`: rebuild. - `stash doctor`: scan without repairing or mutating the catalog. -External catalog files are never written. The managed root is a separate -source of truth owned by `StashLifecycle`. +Catalog operations never write external catalog files. The managed root is a +separate, non-overlapping source of truth owned by `StashLifecycle`. An explicit +lifecycle target can share a root with a search registration, but only the +selected standalone child is in write scope. ## Lifecycle data flow diff --git a/docs/maintenance.md b/docs/maintenance.md index c11a02c..5dd5346 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -90,7 +90,10 @@ lexical failure set justifies their operational cost. Preserve these invariants: -- external catalogs are read-only; +- catalog operations are read-only; explicit archive/deactivate authority is + limited to the exact standalone target or verified Stash-owned deployment; +- managed storage never overlaps an external catalog by equality, nesting, or + filesystem alias; - lifecycle writes are limited to the managed root and explicit standalone targets; - lifecycle never overwrites, follows links, or deletes untracked/drifted paths; diff --git a/docs/vendor-support.md b/docs/vendor-support.md index 8736428..797262f 100644 --- a/docs/vendor-support.md +++ b/docs/vendor-support.md @@ -88,9 +88,10 @@ Because the last point cannot be enforced by metadata, the Adapter documentation Sources: [Antigravity Agent Skills](https://antigravity.google/docs/skills), [Antigravity IDE Plugins](https://antigravity.google/docs/ide/plugins). The current global standalone path is `~/.gemini/config/skills`; workspace -skills use `.agents/skills` (with legacy `.agent/skills` support). Stash can -deploy a directory skill to those roots, but Antigravity documents no -skill-level manual-only override. +skills use `.agents/skills` (with legacy `.agent/skills` support). Stash 0.2 +supports only the documented global user path; workspace, legacy, and arbitrary +custom roots are rejected. Antigravity documents no skill-level manual-only +override. ## Google Antigravity CLI diff --git a/skills/stash/SKILL.md b/skills/stash/SKILL.md index be8a403..ce33fb8 100644 --- a/skills/stash/SKILL.md +++ b/skills/stash/SKILL.md @@ -114,17 +114,20 @@ only the temporary staging directory after a successful managed import. ### Archive a standalone skill -Resolve exactly one standalone skill directory and require its host root: +Resolve exactly one standalone skill directory under the host's documented +user skill root: ```text node archive --host [--scope user] --json ``` -An explicit directory path still requires `--host`; use `--scope custom ---host-root ` when selecting a non-default discovery root. The source -must be an exact child of that root. Explain that archive removes the source -only after a journaled copy, validation, hash check, and commit. Never archive -a plugin-contained skill; delegate plugin lifecycle to the host. +The source must be an exact child of the documented user root. Arbitrary custom +roots and workspace roots are unsupported because Stash cannot prove that the +host discovers them. Explain that archive removes the source only after a +journaled copy, validation, hash check, and commit. Never archive a +plugin-contained skill; delegate plugin lifecycle to the host. If the exact +path is already a verified Stash-owned deployment, archive must use tracked +deactivation semantics and preserve the canonical copy. ### Deploy or withdraw a managed copy diff --git a/skills/stash/scripts/stash.mjs b/skills/stash/scripts/stash.mjs index c1d2319..dc6f863 100644 --- a/skills/stash/scripts/stash.mjs +++ b/skills/stash/scripts/stash.mjs @@ -113,17 +113,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path7) { - const ctrl = callVisitor(key, node, visitor, path7); + function visit_(key, node, visitor, path9) { + const ctrl = callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visit_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visit_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path7); + const ci = visit_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -134,13 +134,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = visit_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = visit_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path7); + const cv = visit_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -161,17 +161,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path7) { - const ctrl = await callVisitor(key, node, visitor, path7); + async function visitAsync_(key, node, visitor, path9) { + const ctrl = await callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path7, ctrl); - return visitAsync_(key, ctrl, visitor, path7); + replaceNode(key, path9, ctrl); + return visitAsync_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path7 = Object.freeze(path7.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path7); + const ci = await visitAsync_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -182,13 +182,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path7 = Object.freeze(path7.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path7); + path9 = Object.freeze(path9.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path7); + const cv = await visitAsync_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -215,23 +215,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path7) { + function callVisitor(key, node, visitor, path9) { if (typeof visitor === "function") - return visitor(key, node, path7); + return visitor(key, node, path9); if (identity.isMap(node)) - return visitor.Map?.(key, node, path7); + return visitor.Map?.(key, node, path9); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path7); + return visitor.Seq?.(key, node, path9); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path7); + return visitor.Pair?.(key, node, path9); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path7); + return visitor.Scalar?.(key, node, path9); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path7); + return visitor.Alias?.(key, node, path9); return void 0; } - function replaceNode(key, path7, node) { - const parent = path7[path7.length - 1]; + function replaceNode(key, path9, node) { + const parent = path9[path9.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -841,10 +841,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path7, value) { + function collectionFromPath(schema, path9, value) { let v = value; - for (let i = path7.length - 1; i >= 0; --i) { - const k = path7[i]; + for (let i = path9.length - 1; i >= 0; --i) { + const k = path9[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -863,7 +863,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path7) => path7 == null || typeof path7 === "object" && !!path7[Symbol.iterator]().next().done; + var isEmptyPath = (path9) => path9 == null || typeof path9 === "object" && !!path9[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -893,11 +893,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path7, value) { - if (isEmptyPath(path7)) + addIn(path9, value) { + if (isEmptyPath(path9)) this.add(value); else { - const [key, ...rest] = path7; + const [key, ...rest] = path9; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -911,8 +911,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - const [key, ...rest] = path7; + deleteIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -926,8 +926,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - const [key, ...rest] = path7; + getIn(path9, keepScalar) { + const [key, ...rest] = path9; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -945,8 +945,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path7) { - const [key, ...rest] = path7; + hasIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -956,8 +956,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - const [key, ...rest] = path7; + setIn(path9, value) { + const [key, ...rest] = path9; if (rest.length === 0) { this.set(key, value); } else { @@ -3472,9 +3472,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path7, value) { + addIn(path9, value) { if (assertCollection(this.contents)) - this.contents.addIn(path7, value); + this.contents.addIn(path9, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -3549,14 +3549,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path7) { - if (Collection.isEmptyPath(path7)) { + deleteIn(path9) { + if (Collection.isEmptyPath(path9)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path7) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path9) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -3571,10 +3571,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path7, keepScalar) { - if (Collection.isEmptyPath(path7)) + getIn(path9, keepScalar) { + if (Collection.isEmptyPath(path9)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path7, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path9, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -3585,10 +3585,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path7) { - if (Collection.isEmptyPath(path7)) + hasIn(path9) { + if (Collection.isEmptyPath(path9)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path7) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path9) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -3605,13 +3605,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path7, value) { - if (Collection.isEmptyPath(path7)) { + setIn(path9, value) { + if (Collection.isEmptyPath(path9)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path7), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path9), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path7, value); + this.contents.setIn(path9, value); } } /** @@ -5571,9 +5571,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path7) => { + visit.itemAtPath = (cst, path9) => { let item = cst; - for (const [field, index] of path7) { + for (const [field, index] of path9) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -5582,23 +5582,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path7) => { - const parent = visit.itemAtPath(cst, path7.slice(0, -1)); - const field = path7[path7.length - 1][0]; + visit.parentCollection = (cst, path9) => { + const parent = visit.itemAtPath(cst, path9.slice(0, -1)); + const field = path9[path9.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path7, item, visitor) { - let ctrl = visitor(item, path7); + function _visit(path9, item, visitor) { + let ctrl = visitor(item, path9); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path7.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path9.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -5609,10 +5609,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path7); + ctrl = ctrl(item, path9); } } - return typeof ctrl === "function" ? ctrl(item, path7) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path9) : ctrl; } exports.visit = visit; } @@ -7364,8 +7364,8 @@ var require_dist = __commonJS({ }); // src/stash-catalog.ts -import { readFile as readFile4, realpath as realpath3, stat as stat4 } from "node:fs/promises"; -import path5 from "node:path"; +import { readFile as readFile5, realpath as realpath4, stat as stat4 } from "node:fs/promises"; +import path6 from "node:path"; // src/types.ts var RESULT_SCHEMA_VERSION = 1; @@ -7383,7 +7383,7 @@ var StashError = class extends Error { // src/internal/configuration.ts var import_yaml = __toESM(require_dist(), 1); -import { readFile, stat } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import path2 from "node:path"; // src/internal/util.ts @@ -7484,6 +7484,10 @@ function isPathInside(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative); } +function pathIdentity(value) { + const normalized = path.resolve(value).normalize("NFKC"); + return platform() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +} function normalizeRelativePath(value) { if (!value || path.isAbsolute(value)) { return void 0; @@ -7818,12 +7822,16 @@ async function includeManagedCatalog(catalogs, managedRoot) { 2 ); } - const normalizedManagedRoot = path2.resolve(managedRoot); - if (catalogs.some((catalog) => { - const normalizedCatalogRoot = path2.resolve(catalog.root); - return process.platform === "win32" ? normalizedCatalogRoot.toLocaleLowerCase("und") === normalizedManagedRoot.toLocaleLowerCase("und") : normalizedCatalogRoot === normalizedManagedRoot; - })) { - return catalogs; + const canonicalManagedRoot = await canonicalPotentialPath(managedRoot); + for (const catalog of catalogs) { + const canonicalCatalogRoot = await canonicalPotentialPath(catalog.root); + if (isPathInside(canonicalCatalogRoot, canonicalManagedRoot) || isPathInside(canonicalManagedRoot, canonicalCatalogRoot)) { + throw new StashError( + "invalid-config", + `Managed root must be separate from external catalog "${catalog.id}": "${managedRoot}" overlaps "${catalog.root}".`, + 2 + ); + } } try { const info = await stat(managedRoot); @@ -7853,6 +7861,28 @@ async function includeManagedCatalog(catalogs, managedRoot) { } ]; } +async function canonicalPotentialPath(input) { + const absolute = path2.resolve(input); + const missingSegments = []; + let candidate = absolute; + while (true) { + try { + const canonical = await realpath(candidate); + return path2.resolve(canonical, ...missingSegments.reverse()); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code !== "ENOENT") { + throw error; + } + const parent = path2.dirname(candidate); + if (parent === candidate) { + return absolute; + } + missingSegments.push(path2.basename(candidate)); + candidate = parent; + } + } +} function validateUniqueCatalogIds(catalogs) { const seen = /* @__PURE__ */ new Set(); for (const catalog of catalogs) { @@ -7874,7 +7904,7 @@ import { open, readFile as readFile2, readdir, - realpath, + realpath as realpath2, rename, stat as stat2, unlink, @@ -8054,7 +8084,7 @@ async function discoverCandidates(catalog) { const warnings = []; let root; try { - root = await realpath(path3.resolve(catalog.root)); + root = await realpath2(path3.resolve(catalog.root)); } catch (error) { throw new StashError( "catalog-unavailable", @@ -8077,7 +8107,7 @@ async function discoverCandidates(catalog) { if (depth > maxDepth) { return; } - const canonicalDirectory = await realpath(directory); + const canonicalDirectory = await realpath2(directory); if (visitedDirectories.has(canonicalDirectory)) { return; } @@ -8130,7 +8160,7 @@ async function discoverCandidates(catalog) { }); continue; } - const target = await realpath(entryPath); + const target = await realpath2(entryPath); if (!isPathInside(root, target)) { warnings.push({ code: "symlink-escape", @@ -8356,86 +8386,205 @@ async function acquireLock(lockPath) { } // src/internal/managed-projection.ts +import { + readFile as readFile4, + readdir as readdir3 +} from "node:fs/promises"; +import path5 from "node:path"; + +// src/internal/tree-fingerprint.ts import { lstat, readFile as readFile3, readdir as readdir2, - realpath as realpath2, + realpath as realpath3, stat as stat3 } from "node:fs/promises"; -import { platform as platform2 } from "node:os"; import path4 from "node:path"; var MAX_FILES = 1e4; var MAX_TOTAL_BYTES = 256 * 1024 * 1024; -function pathIdentity(value) { - const normalized = path4.resolve(value).normalize("NFKC"); - return platform2() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; +var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); +var TreeFingerprintError = class extends Error { + failure; + relativePath; + detail; + constructor(failure, message, relativePath, detail) { + super(message); + this.name = "TreeFingerprintError"; + this.failure = failure; + this.relativePath = relativePath; + this.detail = detail; + } +}; +function validatePortableSegment(segment) { + if (!segment || /[. ]$/u.test(segment) || /[<>:"/\\|?*\u0000-\u001F\u007F]/u.test(segment)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is not portable: "${segment}".`, + segment + ); + } + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is reserved on Windows: "${segment}".`, + segment + ); + } } -function validRecord(value) { +function portablePathKey(relativePath) { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); +} +async function fingerprintTree(sourceRoot, capturePaths = /* @__PURE__ */ new Set()) { + const rootInput = path4.resolve(sourceRoot); + let rootInfo; + try { + rootInfo = await lstat(rootInput); + } catch (error) { + throw new TreeFingerprintError( + "root-unavailable", + `Tree root is unavailable: "${rootInput}".`, + void 0, + error + ); + } + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new TreeFingerprintError( + "unsafe-root", + `Tree root must be a real directory: "${rootInput}".` + ); + } + const root = await realpath3(rootInput); + const entries = []; + const captured = /* @__PURE__ */ new Map(); + const pathKeys = /* @__PURE__ */ new Set(); + let fileCount = 0; + let totalBytes = 0; + async function walk(directory, relativeDirectory) { + const children = await readdir2(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new TreeFingerprintError( + "path-collision", + `Case-insensitive path collision at "${relativePath}".`, + relativePath + ); + } + pathKeys.add(key); + const childPath = path4.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new TreeFingerprintError( + "linked-tree", + `Links are not allowed in a skill tree: "${relativePath}".`, + relativePath + ); + } + if (before.isDirectory()) { + const canonical = await realpath3(childPath); + if (!isPathInside(root, canonical)) { + throw new TreeFingerprintError( + "tree-escape", + `Directory escapes the skill root: "${relativePath}".`, + relativePath + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new TreeFingerprintError( + "special-file", + `Only regular files and directories are allowed: "${relativePath}".`, + relativePath + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new TreeFingerprintError( + "tree-too-large", + `Skill exceeds ${MAX_FILES} files or ${MAX_TOTAL_BYTES} bytes.`, + relativePath + ); + } + const content = await readFile3(childPath); + const after = await stat3(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new TreeFingerprintError( + "tree-changed", + `Skill changed while it was being read: "${relativePath}".`, + relativePath + ); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content) + }); + if (capturePaths.has(relativePath)) { + captured.set(relativePath, content); + } + } + } + await walk(root, ""); + const fingerprint = entries.map( + (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` + ).join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + captured + }; +} + +// src/internal/managed-projection.ts +function validRecord(value, expectedName) { if (!value || typeof value !== "object") { return false; } const record = value; - return record.schemaVersion === 1 && typeof record.skillId === "string" && typeof record.name === "string" && typeof record.treeHash === "string" && record.source !== null && typeof record.source === "object" && typeof record.source.location === "string" && Array.isArray(record.deployments); + return record.schemaVersion === 1 && typeof record.skillId === "string" && record.skillId.length > 0 && record.name === expectedName && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(record.name) && /^sha256:[0-9a-f]{64}$/u.test(record.treeHash) && record.source !== null && typeof record.source === "object" && (record.source.kind === "local-import" || record.source.kind === "standalone-archive") && typeof record.source.location === "string" && path5.isAbsolute(record.source.location) && typeof record.source.importedAt === "string" && (record.source.url === void 0 || typeof record.source.url === "string") && (record.source.revision === void 0 || typeof record.source.revision === "string") && Array.isArray(record.deployments) && record.deployments.every( + (deployment) => deployment !== null && typeof deployment === "object" && typeof deployment.deploymentId === "string" && deployment.deploymentId.length > 0 && deployment.skillId === record.skillId && (deployment.host === "codex" || deployment.host === "claude-code" || deployment.host === "antigravity-ide") && (deployment.scope === "user" || deployment.scope === "workspace") && deployment.method === "copy" && deployment.ownership === "stash" && typeof deployment.root === "string" && path5.isAbsolute(deployment.root) && typeof deployment.path === "string" && path5.isAbsolute(deployment.path) && pathIdentity(deployment.path) === pathIdentity(path5.join(deployment.root, record.name)) && deployment.targetId === `${deployment.host}:${deployment.scope}:${pathIdentity(deployment.root)}` && /^sha256:[0-9a-f]{64}$/u.test(deployment.treeHash) && typeof deployment.deployedAt === "string" + ); } async function treeHash(rootInput) { try { - const rootInfo = await lstat(rootInput); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - return void 0; - } - const root = await realpath2(rootInput); - const entries = []; - let files = 0; - let totalBytes = 0; - async function walk(directory, relativeDirectory) { - const children = await readdir2(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const childPath = path4.join(directory, child.name); - const before = await lstat(childPath); - if (before.isSymbolicLink()) { - throw new Error("linked tree"); - } - if (before.isDirectory()) { - const canonical = await realpath2(childPath); - if (!isPathInside(root, canonical)) { - throw new Error("tree escape"); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new Error("special file"); - } - files += 1; - totalBytes += before.size; - if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { - throw new Error("tree too large"); - } - const content = await readFile3(childPath); - const after = await stat3(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new Error("tree changed"); - } - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash: sha256(content) - }); - } - } - await walk(root, ""); - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); - return sha256(fingerprint); + return (await fingerprintTree(rootInput)).treeHash; } catch { return void 0; } @@ -8473,9 +8622,9 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { return { indexes, fingerprintPart: "" }; } let recordFiles; - const recordsRoot = path4.join(managedRoot, ".stash", "records"); + const recordsRoot = path5.join(managedRoot, ".stash", "records"); try { - recordFiles = (await readdir2(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + recordFiles = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); } catch { return { indexes, fingerprintPart: "" }; } @@ -8484,9 +8633,10 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { for (const file of recordFiles) { try { const parsed = JSON.parse( - await readFile3(path4.join(recordsRoot, file), "utf8") + await readFile4(path5.join(recordsRoot, file), "utf8") ); - if (!validRecord(parsed)) { + const expectedName = file.slice(0, -".json".length); + if (!validRecord(parsed, expectedName)) { throw new Error("invalid record"); } managedRecords.set(parsed.name, parsed); @@ -8513,24 +8663,32 @@ async function projectManagedCopies(sourceIndexes, managedRoot) { }); } } + const events = []; const canonicalBySkillId = /* @__PURE__ */ new Map(); for (const record of managedIndex.records) { const managedRecord = managedRecords.get(record.name); if (managedRecord) { record.managedSkillId = managedRecord.skillId; record.relatedCopies = []; + record.source = { + ...record.source, + ...managedRecord.source.url ? { url: managedRecord.source.url } : {}, + ...managedRecord.source.revision ? { revision: managedRecord.source.revision } : {} + }; + events.push( + `record:${managedRecord.skillId}:${managedRecord.source.url ?? ""}:${managedRecord.source.revision ?? ""}` + ); canonicalBySkillId.set(managedRecord.skillId, record); } } - const events = []; for (const index of indexes) { if (index.catalogId === "managed") { continue; } const visible = []; for (const record of index.records) { - const skillRoot = path4.dirname( - path4.resolve(index.root, record.relativeSkillFile) + const skillRoot = path5.dirname( + path5.resolve(index.root, record.relativeSkillFile) ); const target = targets.get(pathIdentity(skillRoot)); const canonical = target ? canonicalBySkillId.get(target.record.skillId) : void 0; @@ -9257,10 +9415,10 @@ var StashCatalogImplementation = class { resource }; } - const skillRoot = path5.dirname( - path5.resolve(index.root, record.relativeSkillFile) + const skillRoot = path6.dirname( + path6.resolve(index.root, record.relativeSkillFile) ); - const candidatePath = path5.resolve(skillRoot, normalizedResource); + const candidatePath = path6.resolve(skillRoot, normalizedResource); if (!isPathInside(skillRoot, candidatePath) || !isPathInside(index.root, candidatePath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9272,7 +9430,7 @@ var StashCatalogImplementation = class { let resolvedPath; let resolvedSize = 0; try { - resolvedPath = await realpath3(candidatePath); + resolvedPath = await realpath4(candidatePath); if (!isPathInside(skillRoot, resolvedPath) || !isPathInside(index.root, resolvedPath)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9333,7 +9491,7 @@ var StashCatalogImplementation = class { bytes: resolvedSize }; } - const buffer = await readFile4(resolvedPath); + const buffer = await readFile5(resolvedPath); if (buffer.includes(0)) { return { schemaVersion: RESULT_SCHEMA_VERSION, @@ -9454,45 +9612,67 @@ import { randomUUID } from "node:crypto"; import { lstat as lstat2, mkdir as mkdir2, - readFile as readFile5, - readdir as readdir3, - realpath as realpath4, + readFile as readFile6, + readdir as readdir4, + realpath as realpath5, rename as rename2, rm, - stat as stat5, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises"; -import { homedir as homedir2, platform as platform3 } from "node:os"; -import path6 from "node:path"; +import { homedir as homedir3 } from "node:os"; +import path8 from "node:path"; + +// src/internal/lifecycle-host-policy.ts +import { homedir as homedir2 } from "node:os"; +import path7 from "node:path"; +function resolveLifecycleTarget(target, homeDirectory = homedir2()) { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2 + ); + } + if (target.scope === "workspace" || target.workspace) { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2 + ); + } + const scope = target.scope ?? "user"; + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".agents", "skills") + }; + case "claude-code": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".claude", "skills") + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path7.join(homeDirectory, ".gemini", "config", "skills") + }; + } +} +function lifecycleReloadRequired(host) { + return host !== "claude-code"; +} +function lifecycleRefreshObservation(host) { + return lifecycleReloadRequired(host) ? "restart-required" : "live"; +} + +// src/stash-lifecycle.ts var NAME_PATTERN2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -var MAX_FILES2 = 1e4; -var MAX_TOTAL_BYTES2 = 256 * 1024 * 1024; var STORE_SCHEMA_VERSION = 1; -var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([ - "con", - "prn", - "aux", - "nul", - "com1", - "com2", - "com3", - "com4", - "com5", - "com6", - "com7", - "com8", - "com9", - "lpt1", - "lpt2", - "lpt3", - "lpt4", - "lpt5", - "lpt6", - "lpt7", - "lpt8", - "lpt9" -]); function asObject3(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } @@ -9546,143 +9726,45 @@ function parseSkillMetadata(snapshot) { } }; } -function portablePathKey(relativePath) { - return relativePath.normalize("NFKC").toLocaleLowerCase("und"); -} -function validatePortableSegment(segment) { - if (!segment || /[. ]$/u.test(segment)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot end in a dot or space: "${segment}".`, - 3 - ); - } - const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; - if (WINDOWS_RESERVED_NAMES.has(base)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot use the reserved name "${segment}".`, - 3 - ); - } -} async function snapshotTree(sourceRoot) { - const rootInput = path6.resolve(sourceRoot); - const rootInfo = await lstat2(rootInput).catch((error) => { - throw new StashError( - "skill-unavailable", - `Skill directory is unavailable at "${rootInput}": ${String(error)}`, - 4 - ); - }); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - throw new StashError( - "unsafe-skill-tree", - `Skill root must be a real directory, not a link: "${rootInput}".`, - 3 + let fingerprint; + try { + fingerprint = await fingerprintTree( + sourceRoot, + /* @__PURE__ */ new Set(["SKILL.md", "stash.meta.yaml"]) ); - } - const root = await realpath4(rootInput); - const entries = []; - const pathKeys = /* @__PURE__ */ new Set(); - let fileCount = 0; - let totalBytes = 0; - let skillSource; - let sidecarSource; - async function walk(directory, relativeDirectory) { - const children = await readdir3(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - validatePortableSegment(child.name); - const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; - const key = portablePathKey(relativePath); - if (pathKeys.has(key)) { - throw new StashError( - "unsafe-skill-tree", - `Case-insensitive path collision at "${relativePath}".`, - 3 - ); - } - pathKeys.add(key); - const childPath = path6.join(directory, child.name); - const before = await lstat2(childPath); - if (before.isSymbolicLink()) { - throw new StashError( - "unsafe-skill-tree", - `Symlinks and junctions are not allowed: "${relativePath}".`, - 3 - ); - } - if (before.isDirectory()) { - const canonical = await realpath4(childPath); - if (!isPathInside(root, canonical)) { - throw new StashError( - "unsafe-skill-tree", - `Directory escapes the skill root: "${relativePath}".`, - 3 - ); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new StashError( - "unsafe-skill-tree", - `Only regular files and directories are allowed: "${relativePath}".`, - 3 - ); - } - fileCount += 1; - totalBytes += before.size; - if (fileCount > MAX_FILES2 || totalBytes > MAX_TOTAL_BYTES2) { - throw new StashError( - "skill-too-large", - `Skill exceeds ${MAX_FILES2} files or ${MAX_TOTAL_BYTES2} bytes.`, - 3 - ); - } - const content = await readFile5(childPath); - const after = await stat5(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new StashError( - "source-changed", - `Skill changed while it was being read: "${relativePath}".`, - 4 - ); - } - const contentHash = sha256(content); - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash - }); - if (relativePath === "SKILL.md") { - skillSource = content.toString("utf8"); - } else if (relativePath === "stash.meta.yaml") { - sidecarSource = content.toString("utf8"); - } + } catch (error) { + if (!(error instanceof TreeFingerprintError)) { + throw error; } + if (error.failure === "root-unavailable") { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${path8.resolve(sourceRoot)}": ${String(error.detail ?? error.message)}`, + 4 + ); + } + if (error.failure === "tree-too-large") { + throw new StashError("skill-too-large", error.message, 3); + } + if (error.failure === "tree-changed") { + throw new StashError("source-changed", error.message, 4); + } + throw new StashError("unsafe-skill-tree", error.message, 3); } - await walk(root, ""); + const skillSource = fingerprint.captured.get("SKILL.md")?.toString("utf8"); if (skillSource === void 0) { throw new StashError( "invalid-skill", - `Skill root must contain SKILL.md: "${root}".`, + `Skill root must contain SKILL.md: "${fingerprint.root}".`, 3 ); } - const fingerprint = entries.map( - (entry) => entry.kind === "directory" ? `D\0${entry.relativePath}` : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}` - ).join("\n"); + const sidecarSource = fingerprint.captured.get("stash.meta.yaml")?.toString("utf8"); return { - root, - treeHash: sha256(fingerprint), - entries, + root: fingerprint.root, + treeHash: fingerprint.treeHash, + entries: fingerprint.entries, skillSource, ...sidecarSource !== void 0 ? { sidecarSource } : {} }; @@ -9690,14 +9772,14 @@ async function snapshotTree(sourceRoot) { async function copySnapshot(snapshot, destination) { await mkdir2(destination, { recursive: false }); for (const entry of snapshot.entries) { - const relative = entry.relativePath.split("/").join(path6.sep); - const target = path6.join(destination, relative); + const relative = entry.relativePath.split("/").join(path8.sep); + const target = path8.join(destination, relative); if (entry.kind === "directory") { await mkdir2(target, { recursive: false }); continue; } - const source = path6.join(snapshot.root, relative); - const content = await readFile5(source); + const source = path8.join(snapshot.root, relative); + const content = await readFile6(source); if (sha256(content) !== entry.contentHash) { throw new StashError( "source-changed", @@ -9727,82 +9809,25 @@ async function pathType(target) { } } function samePath(left, right) { - const normalizedLeft = path6.resolve(left).normalize("NFKC"); - const normalizedRight = path6.resolve(right).normalize("NFKC"); - return platform3() === "win32" ? normalizedLeft.toLocaleLowerCase("und") === normalizedRight.toLocaleLowerCase("und") : normalizedLeft === normalizedRight; -} -function pathIdentity2(value) { - const normalized = path6.resolve(value).normalize("NFKC"); - return platform3() === "win32" ? normalized.toLocaleLowerCase("und") : normalized; + return pathIdentity(left) === pathIdentity(right); } function targetIdentity(target) { - return `${target.host}:${target.scope}:${pathIdentity2(target.root)}`; -} -function resolveHostRoot(target) { - if (target.host === "antigravity-cli") { - throw new StashError( - "unsupported-host-layout", - "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", - 2 - ); - } - if (target.scope === "workspace") { - throw new StashError( - "unsupported-host-scope", - "Workspace lifecycle targets are not supported in this release.", - 2 - ); - } - if (target.root) { - return { - host: target.host, - scope: target.scope ?? "custom", - root: path6.resolve(target.root) - }; - } - const scope = target.scope ?? "user"; - if (scope === "custom") { - throw new StashError( - "invalid-argument", - "A custom lifecycle target requires root.", - 2 - ); - } - switch (target.host) { - case "codex": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".agents", "skills") - }; - case "claude-code": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".claude", "skills") - }; - case "antigravity-ide": - return { - host: target.host, - scope, - root: path6.join(homedir2(), ".gemini", "config", "skills") - }; - } + return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } async function isPluginContained(source) { - let current = path6.dirname(source); + let current = path8.dirname(source); for (let depth = 0; depth < 12; depth += 1) { const markers = [ - path6.join(current, ".claude-plugin", "plugin.json"), - path6.join(current, ".codex-plugin", "plugin.json"), - path6.join(current, "plugin.json") + path8.join(current, ".claude-plugin", "plugin.json"), + path8.join(current, ".codex-plugin", "plugin.json"), + path8.join(current, "plugin.json") ]; for (const marker of markers) { if (await pathType(marker) !== "missing") { return true; } } - const parent = path6.dirname(current); + const parent = path8.dirname(current); if (parent === current) { break; } @@ -9813,21 +9838,22 @@ async function isPluginContained(source) { var StashLifecycleImplementation = class { #managedRoot; #now; + #lifecycleHome = path8.resolve(homedir3()); constructor(managedRoot, now) { - this.#managedRoot = path6.resolve(managedRoot); + this.#managedRoot = path8.resolve(managedRoot); this.#now = now; } #metadataRoot() { - return path6.join(this.#managedRoot, ".stash"); + return path8.join(this.#managedRoot, ".stash"); } #recordPath(name) { - return path6.join(this.#metadataRoot(), "records", `${name}.json`); + return path8.join(this.#metadataRoot(), "records", `${name}.json`); } async #ensureLayout() { - await mkdir2(path6.join(this.#metadataRoot(), "records"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "staging"), { recursive: true }); - await mkdir2(path6.join(this.#metadataRoot(), "journal"), { recursive: true }); - const manifestPath = path6.join(this.#managedRoot, "stash.catalog.yaml"); + await mkdir2(path8.join(this.#metadataRoot(), "records"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "staging"), { recursive: true }); + await mkdir2(path8.join(this.#metadataRoot(), "journal"), { recursive: true }); + const manifestPath = path8.join(this.#managedRoot, "stash.catalog.yaml"); try { await writeFile2( manifestPath, @@ -9842,7 +9868,7 @@ var StashLifecycleImplementation = class { } } #journalPath(operationId) { - return path6.join(this.#metadataRoot(), "journal", `${operationId}.json`); + return path8.join(this.#metadataRoot(), "journal", `${operationId}.json`); } async #writeJournal(journal) { const finalPath = this.#journalPath(journal.operationId); @@ -9901,16 +9927,16 @@ var StashLifecycleImplementation = class { "source-tombstoned", "archive-committed" ]); - if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path6.isAbsolute(journal.source) || !path6.isAbsolute(journal.tombstone) || !path6.isAbsolute(journal.managedPath)) { + if (journal.schemaVersion !== 1 || !/^[0-9a-f-]{36}$/iu.test(journal.operationId) || !stages.has(journal.stage) || !NAME_PATTERN2.test(journal.name) || !/^sha256:[0-9a-f]{64}$/iu.test(journal.treeHash) || typeof journal.managedExistedBefore !== "boolean" || typeof journal.createdAt !== "string" || typeof journal.source !== "string" || typeof journal.tombstone !== "string" || typeof journal.managedPath !== "string" || !path8.isAbsolute(journal.source) || !path8.isAbsolute(journal.tombstone) || !path8.isAbsolute(journal.managedPath)) { throw new StashError( "invalid-lifecycle-journal", `Invalid or unsafe lifecycle journal "${journalPath}".`, 5 ); } - const expectedManagedPath = path6.join(this.#managedRoot, journal.name); - const expectedTombstoneParent = path6.dirname(path6.dirname(journal.source)); - if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path6.dirname(journal.tombstone), expectedTombstoneParent) || !path6.basename(journal.tombstone).startsWith( + const expectedManagedPath = path8.join(this.#managedRoot, journal.name); + const expectedTombstoneParent = path8.dirname(path8.dirname(journal.source)); + if (!samePath(journal.managedPath, expectedManagedPath) || !samePath(path8.dirname(journal.tombstone), expectedTombstoneParent) || !path8.basename(journal.tombstone).startsWith( `.stash-archive-${journal.name}-` ) || isPathInside(this.#managedRoot, journal.source) || isPathInside(this.#managedRoot, journal.tombstone)) { throw new StashError( @@ -9975,13 +10001,13 @@ var StashLifecycleImplementation = class { await unlink2(journalPath); } async #recoverJournals() { - const journalRoot = path6.join(this.#metadataRoot(), "journal"); - const files = (await readdir3(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); + const journalRoot = path8.join(this.#metadataRoot(), "journal"); + const files = (await readdir4(journalRoot)).filter((name) => name.endsWith(".json")).sort((left, right) => left.localeCompare(right, "en")); for (const file of files) { - const journalPath = path6.join(journalRoot, file); + const journalPath = path8.join(journalRoot, file); let journal; try { - journal = JSON.parse(await readFile5(journalPath, "utf8")); + journal = JSON.parse(await readFile6(journalPath, "utf8")); } catch (error) { throw new StashError( "invalid-lifecycle-journal", @@ -9997,7 +10023,7 @@ var StashLifecycleImplementation = class { let owner; try { owner = JSON.parse( - await readFile5(path6.join(lockPath, "owner.json"), "utf8") + await readFile6(path8.join(lockPath, "owner.json"), "utf8") ); } catch (error) { throw new StashError( @@ -10015,23 +10041,24 @@ var StashLifecycleImplementation = class { } return owner; } - #ownerIsAlive(owner) { + #ownerState(owner) { try { process.kill(owner.pid, 0); - return true; - } catch { - return false; + return "alive"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + return code === "ESRCH" ? "dead" : "unknown"; } } async #publishLock(lockPath, owner) { - const temporaryPath = path6.join( + const temporaryPath = path8.join( this.#metadataRoot(), `.lifecycle-lock-${owner.ownerToken}.tmp` ); await mkdir2(temporaryPath, { recursive: false }); try { await writeFile2( - path6.join(temporaryPath, "owner.json"), + path8.join(temporaryPath, "owner.json"), `${JSON.stringify(owner)} `, { encoding: "utf8", flag: "wx" } @@ -10052,7 +10079,7 @@ var StashLifecycleImplementation = class { } } async #reclaimDeadLock(lockPath) { - const guardPath = path6.join(this.#metadataRoot(), "lifecycle.reclaim"); + const guardPath = path8.join(this.#metadataRoot(), "lifecycle.reclaim"); try { await mkdir2(guardPath, { recursive: false }); } catch (error) { @@ -10077,14 +10104,14 @@ var StashLifecycleImplementation = class { ); } const owner = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(owner)) { + if (this.#ownerState(owner) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } - const reclaimedPath = path6.join( + const reclaimedPath = path8.join( this.#metadataRoot(), `.lifecycle-reclaimed-${owner.ownerToken}-${randomUUID()}` ); @@ -10116,10 +10143,10 @@ var StashLifecycleImplementation = class { return owner; } const existing = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(existing)) { + if (this.#ownerState(existing) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4 ); } @@ -10144,7 +10171,7 @@ var StashLifecycleImplementation = class { } async #withLock(operation) { await this.#ensureLayout(); - const lockPath = path6.join(this.#metadataRoot(), "lifecycle.lock"); + const lockPath = path8.join(this.#metadataRoot(), "lifecycle.lock"); const owner = await this.#acquireLock(lockPath); try { await this.#recoverJournals(); @@ -10156,9 +10183,9 @@ var StashLifecycleImplementation = class { async #readRecord(name) { const recordPath = this.#recordPath(name); try { - const parsed = JSON.parse(await readFile5(recordPath, "utf8")); + const parsed = JSON.parse(await readFile6(recordPath, "utf8")); if (parsed.schemaVersion !== STORE_SCHEMA_VERSION || typeof parsed.skillId !== "string" || parsed.skillId.length === 0 || parsed.name !== name || typeof parsed.treeHash !== "string" || !Array.isArray(parsed.deployments) || parsed.deployments.some( - (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path6.join(deployment.root, parsed.name)) + (deployment) => typeof deployment.deploymentId !== "string" || deployment.skillId !== parsed.skillId || typeof deployment.targetId !== "string" || deployment.targetId !== targetIdentity(deployment) || deployment.ownership !== "stash" || !samePath(deployment.path, path8.join(deployment.root, parsed.name)) )) { throw new Error("invalid lifecycle record shape"); } @@ -10191,8 +10218,8 @@ var StashLifecycleImplementation = class { } } async #assertSourceBoundary(source) { - const canonicalSource = await realpath4(source); - const canonicalManaged = await realpath4(this.#managedRoot); + const canonicalSource = await realpath5(source); + const canonicalManaged = await realpath5(this.#managedRoot); if (isPathInside(canonicalManaged, canonicalSource) || isPathInside(canonicalSource, canonicalManaged)) { throw new StashError( "unsafe-source", @@ -10208,7 +10235,7 @@ var StashLifecycleImplementation = class { type = await pathType(root); } if (type === "missing" && allowMissing) { - return path6.resolve(root); + return path8.resolve(root); } if (type !== "directory") { throw new StashError( @@ -10217,7 +10244,7 @@ var StashLifecycleImplementation = class { 3 ); } - return realpath4(root); + return realpath5(root); } async #storeSource(source, kind, sourceUrl, revision, expectedTreeHash) { await this.#assertSourceBoundary(source); @@ -10230,7 +10257,7 @@ var StashLifecycleImplementation = class { ); } const metadata = parseSkillMetadata(snapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); + const managedPath = path8.join(this.#managedRoot, metadata.name); const existingType = await pathType(managedPath); if (existingType !== "missing") { if (existingType !== "directory") { @@ -10262,7 +10289,7 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join(this.#metadataRoot(), "staging", randomUUID()); + const stage = path8.join(this.#metadataRoot(), "staging", randomUUID()); let committed = false; try { await copySnapshot(snapshot, stage); @@ -10311,7 +10338,7 @@ var StashLifecycleImplementation = class { async install(request) { return this.#withLock(async () => { const stored = await this.#storeSource( - path6.resolve(request.source), + path8.resolve(request.source), "local-import", request.sourceUrl, request.revision @@ -10327,16 +10354,19 @@ var StashLifecycleImplementation = class { } async archive(request) { return this.#withLock(async () => { - const resolvedTarget = resolveHostRoot(request.target); + const resolvedTarget = resolveLifecycleTarget( + request.target, + this.#lifecycleHome + ); resolvedTarget.root = await this.#canonicalHostRoot( resolvedTarget.root, false ); const looksLikeName = NAME_PATTERN2.test(request.source); - const source = path6.resolve( - looksLikeName ? path6.join(resolvedTarget.root, request.source) : request.source + const source = path8.resolve( + looksLikeName ? path8.join(resolvedTarget.root, request.source) : request.source ); - if (!samePath(path6.dirname(source), resolvedTarget.root)) { + if (!samePath(path8.dirname(source), resolvedTarget.root)) { throw new StashError( "unsafe-archive-source", `Archive source must be an exact standalone child of the selected host root "${resolvedTarget.root}".`, @@ -10353,11 +10383,39 @@ var StashLifecycleImplementation = class { await this.#assertSourceBoundary(source); const sourceSnapshot = await snapshotTree(source); const metadata = parseSkillMetadata(sourceSnapshot); - const managedPath = path6.join(this.#managedRoot, metadata.name); - const managedExistedBefore = await pathType(managedPath) !== "missing" || await this.#readRecord(metadata.name) !== void 0; - const tombstoneParent = path6.dirname(resolvedTarget.root); + const managedPath = path8.join(this.#managedRoot, metadata.name); + const existingRecord = await this.#readRecord(metadata.name); + const managedType = await pathType(managedPath); + const selectedTargetId = targetIdentity(resolvedTarget); + const trackedDeployment = existingRecord?.deployments.find( + (deployment) => samePath(deployment.path, source) && deployment.targetId === selectedTargetId + ); + if (existingRecord && trackedDeployment) { + if (managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${metadata.name}" is unavailable; refusing to archive its tracked deployment.`, + 3 + ); + } + const managedSnapshot = await snapshotTree(managedPath); + if (managedSnapshot.treeHash !== existingRecord.treeHash || sourceSnapshot.treeHash !== existingRecord.treeHash || trackedDeployment.treeHash !== existingRecord.treeHash) { + throw new StashError( + "managed-drift", + `Managed skill or tracked deployment "${metadata.name}" drifted; refusing archive.`, + 3 + ); + } + return this.#deactivateDeployment( + existingRecord, + managedPath, + resolvedTarget + ); + } + const managedExistedBefore = managedType !== "missing" || existingRecord !== void 0; + const tombstoneParent = path8.dirname(resolvedTarget.root); await mkdir2(tombstoneParent, { recursive: true }); - const tombstone = path6.join( + const tombstone = path8.join( tombstoneParent, `.stash-archive-${metadata.name}-${randomUUID()}` ); @@ -10444,7 +10502,7 @@ var StashLifecycleImplementation = class { 4 ); } - const managedPath = path6.join(this.#managedRoot, request.name); + const managedPath = path8.join(this.#managedRoot, request.name); const managedSnapshot = await snapshotTree(managedPath); if (managedSnapshot.treeHash !== record.treeHash) { throw new StashError( @@ -10453,9 +10511,9 @@ var StashLifecycleImplementation = class { 3 ); } - const target = resolveHostRoot(request.target); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); target.root = await this.#canonicalHostRoot(target.root, true); - const deploymentPath = path6.join(target.root, request.name); + const deploymentPath = path8.join(target.root, request.name); const targetId = targetIdentity(target); const existing = await pathType(deploymentPath); const tracked = record.deployments.find( @@ -10472,7 +10530,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment: tracked, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } @@ -10483,8 +10541,8 @@ var StashLifecycleImplementation = class { 3 ); } - const stage = path6.join( - path6.dirname(target.root), + const stage = path8.join( + path8.dirname(target.root), `.stash-deploy-${record.name}-${randomUUID()}` ); let committed = false; @@ -10535,7 +10593,7 @@ var StashLifecycleImplementation = class { managedPath, treeHash: record.treeHash, deployment, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed." }; } finally { @@ -10545,120 +10603,123 @@ var StashLifecycleImplementation = class { } }); } - async deactivate(request) { - return this.#withLock(async () => { - const record = await this.#readRecord(request.name); - if (!record) { - throw new StashError( - "managed-skill-not-found", - `Managed skill "${request.name}" was not found.`, - 4 - ); - } - const managedPath = path6.join(this.#managedRoot, request.name); - const target = resolveHostRoot(request.target); - target.root = await this.#canonicalHostRoot(target.root, false, true); - const deploymentPath = path6.join(target.root, request.name); - const targetId = targetIdentity(target); - const deployment = record.deployments.find( - (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + async #deactivateDeployment(record, managedPath, target) { + const deploymentPath = path8.join(target.root, record.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => samePath(candidate.path, deploymentPath) && candidate.targetId === targetId + ); + if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3 ); - if (!deployment || deployment.ownership !== "stash" || deployment.skillId !== record.skillId) { - throw new StashError( - "detached-deployment", - `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, - 3 - ); - } - const existing = await pathType(deploymentPath); - if (existing === "missing") { - record.deployments = record.deployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - await this.#writeRecord(record); - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - warning: "The tracked deployment was already missing." - }; - } - if (existing !== "directory") { - throw new StashError( - "deployment-drift", - `Tracked deployment is no longer a real directory: "${deploymentPath}".`, - 3 - ); - } - const deployedSnapshot = await snapshotTree(deploymentPath); - if (deployedSnapshot.treeHash !== deployment.treeHash) { + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing." + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3 + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3 + ); + } + const tombstone = path8.join( + path8.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}` + ); + await rename2(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename2(tombstone, deploymentPath); throw new StashError( "deployment-drift", - `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, 3 ); } - const tombstone = path6.join( - path6.dirname(target.root), - `.stash-deactivate-${record.name}-${randomUUID()}` + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath) ); - await rename2(deploymentPath, tombstone); + record.lastValidatedAt = new Date(this.#now()).toISOString(); try { - const movedSnapshot = await snapshotTree(tombstone); - if (movedSnapshot.treeHash !== deployment.treeHash) { - await rename2(tombstone, deploymentPath); - throw new StashError( - "deployment-drift", - `Deployment changed during deactivation and was restored: "${deploymentPath}".`, - 3 - ); - } - const previousDeployments = record.deployments; - record.deployments = previousDeployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath) - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - try { - await this.#writeRecord(record); - } catch (error) { - record.deployments = previousDeployments; - await rename2(tombstone, deploymentPath).catch(() => void 0); - throw error; - } - let warning; - try { - await rm(tombstone, { recursive: true, force: false }); - } catch (error) { - warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; - } - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - reloadRequired: target.host !== "claude-code", - ...warning ? { warning } : {} - }; + await this.#writeRecord(record); } catch (error) { - if (await pathType(tombstone) !== "missing") { - await rename2(tombstone, deploymentPath).catch(() => void 0); - } + record.deployments = previousDeployments; + await rename2(tombstone, deploymentPath).catch(() => void 0); throw error; } + let warning; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: lifecycleReloadRequired(target.host), + ...warning ? { warning } : {} + }; + } catch (error) { + if (await pathType(tombstone) !== "missing") { + await rename2(tombstone, deploymentPath).catch(() => void 0); + } + throw error; + } + } + async deactivate(request) { + return this.#withLock(async () => { + const record = await this.#readRecord(request.name); + if (!record) { + throw new StashError( + "managed-skill-not-found", + `Managed skill "${request.name}" was not found.`, + 4 + ); + } + const managedPath = path8.join(this.#managedRoot, request.name); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); + target.root = await this.#canonicalHostRoot(target.root, false, true); + return this.#deactivateDeployment(record, managedPath, target); }); } async status(request = {}) { - const recordsRoot = path6.join(this.#metadataRoot(), "records"); + const recordsRoot = path8.join(this.#metadataRoot(), "records"); let names; if (request.name) { names = [request.name]; } else { try { - names = (await readdir3(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); + names = (await readdir4(recordsRoot)).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -".json".length)).sort((left, right) => left.localeCompare(right, "en")); } catch (error) { const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; if (code === "ENOENT") { @@ -10674,7 +10735,7 @@ var StashLifecycleImplementation = class { if (!record) { continue; } - const managedPath = path6.join(this.#managedRoot, name); + const managedPath = path8.join(this.#managedRoot, name); const managedType = await pathType(managedPath); let storeState = managedType === "missing" ? "missing" : "stored"; let storeIntegrity = managedType === "directory" ? "unknown" : managedType === "missing" ? "unknown" : "drifted"; @@ -10698,7 +10759,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "absent", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10711,7 +10772,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); continue; @@ -10726,7 +10787,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "present", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } catch { @@ -10737,7 +10798,7 @@ var StashLifecycleImplementation = class { hostObservation: { override: "unknown", discovery: "unknown", - refresh: deployment.host === "claude-code" ? "live" : "restart-required" + refresh: lifecycleRefreshObservation(deployment.host) } }); } @@ -10764,8 +10825,14 @@ var StashLifecycleImplementation = class { } }; async function createStashLifecycle(options = {}) { - const loaded = await loadConfiguration(options); - const managedRoot = path6.resolve( + const effectiveOptions = options.catalogs && !options.managedRoot ? { + ...options, + managedRoot: path8.resolve( + process.env.STASH_MANAGED_HOME ?? platformManagedPath() + ) + } : options; + const loaded = await loadConfiguration(effectiveOptions); + const managedRoot = path8.resolve( loaded.configuration.managedRoot ?? options.managedRoot ?? platformManagedPath() ); return new StashLifecycleImplementation( @@ -10865,19 +10932,25 @@ function lifecycleTarget(args) { ); } const scope = flag(args, "scope"); - if (scope !== void 0 && scope !== "user" && scope !== "workspace" && scope !== "custom") { + if (scope !== void 0 && scope !== "user" && scope !== "workspace") { throw new StashError( "invalid-argument", - "--scope must be user, workspace, or custom.", + "--scope must be user or workspace.", 2 ); } const root = flag(args, "host-root"); + if (root) { + throw new StashError( + "unsupported-host-root", + "Custom host roots are not supported; lifecycle targets use documented user skill directories.", + 2 + ); + } const workspace = flag(args, "workspace"); return { host, ...scope ? { scope } : {}, - ...root ? { root } : {}, ...workspace ? { workspace } : {} }; } @@ -10968,8 +11041,7 @@ Configuration: Lifecycle targeting: --host codex, claude-code, antigravity-ide, or antigravity-cli. - --scope user or custom (default: user; workspace is rejected). - --host-root Explicit discovery root; implies a custom location. + --scope user (default); workspace is rejected in this release. Result pagination never caps the total relevant result set. Lifecycle commands manage only the Stash-owned store and explicitly selected diff --git a/src/cli.ts b/src/cli.ts index b912310..247208f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -122,21 +122,26 @@ function lifecycleTarget(args: ParsedArguments): LifecycleHostTarget { if ( scope !== undefined && scope !== "user" && - scope !== "workspace" && - scope !== "custom" + scope !== "workspace" ) { throw new StashError( "invalid-argument", - "--scope must be user, workspace, or custom.", + "--scope must be user or workspace.", 2, ); } const root = flag(args, "host-root"); + if (root) { + throw new StashError( + "unsupported-host-root", + "Custom host roots are not supported; lifecycle targets use documented user skill directories.", + 2, + ); + } const workspace = flag(args, "workspace"); return { host: host as LifecycleHost, ...(scope ? { scope } : {}), - ...(root ? { root } : {}), ...(workspace ? { workspace } : {}), }; } @@ -230,8 +235,7 @@ Configuration: Lifecycle targeting: --host codex, claude-code, antigravity-ide, or antigravity-cli. - --scope user or custom (default: user; workspace is rejected). - --host-root Explicit discovery root; implies a custom location. + --scope user (default); workspace is rejected in this release. Result pagination never caps the total relevant result set. Lifecycle commands manage only the Stash-owned store and explicitly selected diff --git a/src/index.ts b/src/index.ts index 5266d4c..34cb4f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export type { CatalogSource, CatalogWarning, CreateStashCatalogOptions, + CreateStashLifecycleOptions, DoctorRequest, DoctorResult, LifecycleActivateRequest, diff --git a/src/internal/configuration.ts b/src/internal/configuration.ts index 9820a8b..82e89ba 100644 --- a/src/internal/configuration.ts +++ b/src/internal/configuration.ts @@ -1,4 +1,4 @@ -import { readFile, stat } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { parse } from "yaml"; import type { @@ -10,6 +10,7 @@ import type { } from "../types.js"; import { StashError } from "../types.js"; import { + isPathInside, platformCachePath, platformConfigPath, platformManagedPath, @@ -270,17 +271,19 @@ async function includeManagedCatalog( 2, ); } - const normalizedManagedRoot = path.resolve(managedRoot); - if ( - catalogs.some((catalog) => { - const normalizedCatalogRoot = path.resolve(catalog.root); - return process.platform === "win32" - ? normalizedCatalogRoot.toLocaleLowerCase("und") === - normalizedManagedRoot.toLocaleLowerCase("und") - : normalizedCatalogRoot === normalizedManagedRoot; - }) - ) { - return catalogs; + const canonicalManagedRoot = await canonicalPotentialPath(managedRoot); + for (const catalog of catalogs) { + const canonicalCatalogRoot = await canonicalPotentialPath(catalog.root); + if ( + isPathInside(canonicalCatalogRoot, canonicalManagedRoot) || + isPathInside(canonicalManagedRoot, canonicalCatalogRoot) + ) { + throw new StashError( + "invalid-config", + `Managed root must be separate from external catalog "${catalog.id}": "${managedRoot}" overlaps "${catalog.root}".`, + 2, + ); + } } try { const info = await stat(managedRoot); @@ -314,6 +317,32 @@ async function includeManagedCatalog( ]; } +async function canonicalPotentialPath(input: string): Promise { + const absolute = path.resolve(input); + const missingSegments: string[] = []; + let candidate = absolute; + while (true) { + try { + const canonical = await realpath(candidate); + return path.resolve(canonical, ...missingSegments.reverse()); + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + if (code !== "ENOENT") { + throw error; + } + const parent = path.dirname(candidate); + if (parent === candidate) { + return absolute; + } + missingSegments.push(path.basename(candidate)); + candidate = parent; + } + } +} + function validateUniqueCatalogIds(catalogs: CatalogRegistration[]): void { const seen = new Set(); for (const catalog of catalogs) { diff --git a/src/internal/lifecycle-host-policy.ts b/src/internal/lifecycle-host-policy.ts new file mode 100644 index 0000000..9e285c5 --- /dev/null +++ b/src/internal/lifecycle-host-policy.ts @@ -0,0 +1,65 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import type { + LifecycleHost, + LifecycleHostTarget, + LifecycleScope, +} from "../types.js"; +import { StashError } from "../types.js"; + +export interface ResolvedLifecycleTarget { + host: LifecycleHost; + scope: LifecycleScope; + root: string; +} + +export function resolveLifecycleTarget( + target: LifecycleHostTarget, + homeDirectory = homedir(), +): ResolvedLifecycleTarget { + if (target.host === "antigravity-cli") { + throw new StashError( + "unsupported-host-layout", + "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", + 2, + ); + } + if (target.scope === "workspace" || target.workspace) { + throw new StashError( + "unsupported-host-scope", + "Workspace lifecycle targets are not supported in this release.", + 2, + ); + } + const scope = target.scope ?? "user"; + switch (target.host) { + case "codex": + return { + host: target.host, + scope, + root: path.join(homeDirectory, ".agents", "skills"), + }; + case "claude-code": + return { + host: target.host, + scope, + root: path.join(homeDirectory, ".claude", "skills"), + }; + case "antigravity-ide": + return { + host: target.host, + scope, + root: path.join(homeDirectory, ".gemini", "config", "skills"), + }; + } +} + +export function lifecycleReloadRequired(host: LifecycleHost): boolean { + return host !== "claude-code"; +} + +export function lifecycleRefreshObservation( + host: LifecycleHost, +): "live" | "restart-required" { + return lifecycleReloadRequired(host) ? "restart-required" : "live"; +} diff --git a/src/internal/managed-projection.ts b/src/internal/managed-projection.ts index e8c5164..3104227 100644 --- a/src/internal/managed-projection.ts +++ b/src/internal/managed-projection.ts @@ -1,11 +1,7 @@ import { - lstat, readFile, readdir, - realpath, - stat, } from "node:fs/promises"; -import { platform } from "node:os"; import path from "node:path"; import type { CatalogIndex, @@ -13,17 +9,8 @@ import type { RelatedSkillCopy, SkillRecord, } from "../types.js"; -import { isPathInside, sha256 } from "./util.js"; - -const MAX_FILES = 10_000; -const MAX_TOTAL_BYTES = 256 * 1024 * 1024; - -interface TreeEntry { - kind: "directory" | "file"; - relativePath: string; - size?: number; - contentHash?: string; -} +import { fingerprintTree } from "./tree-fingerprint.js"; +import { pathIdentity, sha256 } from "./util.js"; interface ProjectionTarget { kind: RelatedSkillCopy["kind"]; @@ -32,14 +19,10 @@ interface ProjectionTarget { deployment?: ManagedSkillRecord["deployments"][number]; } -function pathIdentity(value: string): string { - const normalized = path.resolve(value).normalize("NFKC"); - return platform() === "win32" - ? normalized.toLocaleLowerCase("und") - : normalized; -} - -function validRecord(value: unknown): value is ManagedSkillRecord { +function validRecord( + value: unknown, + expectedName: string, +): value is ManagedSkillRecord { if (!value || typeof value !== "object") { return false; } @@ -47,81 +30,52 @@ function validRecord(value: unknown): value is ManagedSkillRecord { return ( record.schemaVersion === 1 && typeof record.skillId === "string" && - typeof record.name === "string" && - typeof record.treeHash === "string" && + record.skillId.length > 0 && + record.name === expectedName && + /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(record.name) && + /^sha256:[0-9a-f]{64}$/u.test(record.treeHash) && record.source !== null && typeof record.source === "object" && + (record.source.kind === "local-import" || + record.source.kind === "standalone-archive") && typeof record.source.location === "string" && - Array.isArray(record.deployments) + path.isAbsolute(record.source.location) && + typeof record.source.importedAt === "string" && + (record.source.url === undefined || + typeof record.source.url === "string") && + (record.source.revision === undefined || + typeof record.source.revision === "string") && + Array.isArray(record.deployments) && + record.deployments.every( + (deployment) => + deployment !== null && + typeof deployment === "object" && + typeof deployment.deploymentId === "string" && + deployment.deploymentId.length > 0 && + deployment.skillId === record.skillId && + (deployment.host === "codex" || + deployment.host === "claude-code" || + deployment.host === "antigravity-ide") && + (deployment.scope === "user" || deployment.scope === "workspace") && + deployment.method === "copy" && + deployment.ownership === "stash" && + typeof deployment.root === "string" && + path.isAbsolute(deployment.root) && + typeof deployment.path === "string" && + path.isAbsolute(deployment.path) && + pathIdentity(deployment.path) === + pathIdentity(path.join(deployment.root, record.name)) && + deployment.targetId === + `${deployment.host}:${deployment.scope}:${pathIdentity(deployment.root)}` && + /^sha256:[0-9a-f]{64}$/u.test(deployment.treeHash) && + typeof deployment.deployedAt === "string", + ) ); } async function treeHash(rootInput: string): Promise { try { - const rootInfo = await lstat(rootInput); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - return undefined; - } - const root = await realpath(rootInput); - const entries: TreeEntry[] = []; - let files = 0; - let totalBytes = 0; - - async function walk(directory: string, relativeDirectory: string) { - const children = await readdir(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - const relativePath = relativeDirectory - ? `${relativeDirectory}/${child.name}` - : child.name; - const childPath = path.join(directory, child.name); - const before = await lstat(childPath); - if (before.isSymbolicLink()) { - throw new Error("linked tree"); - } - if (before.isDirectory()) { - const canonical = await realpath(childPath); - if (!isPathInside(root, canonical)) { - throw new Error("tree escape"); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new Error("special file"); - } - files += 1; - totalBytes += before.size; - if (files > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { - throw new Error("tree too large"); - } - const content = await readFile(childPath); - const after = await stat(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new Error("tree changed"); - } - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash: sha256(content), - }); - } - } - - await walk(root, ""); - const fingerprint = entries - .map((entry) => - entry.kind === "directory" - ? `D\0${entry.relativePath}` - : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}`, - ) - .join("\n"); - return sha256(fingerprint); + return (await fingerprintTree(rootInput)).treeHash; } catch { return undefined; } @@ -184,7 +138,8 @@ export async function projectManagedCopies( const parsed = JSON.parse( await readFile(path.join(recordsRoot, file), "utf8"), ) as unknown; - if (!validRecord(parsed)) { + const expectedName = file.slice(0, -".json".length); + if (!validRecord(parsed, expectedName)) { throw new Error("invalid record"); } managedRecords.set(parsed.name, parsed); @@ -215,17 +170,29 @@ export async function projectManagedCopies( } } + const events: string[] = []; const canonicalBySkillId = new Map(); for (const record of managedIndex.records) { const managedRecord = managedRecords.get(record.name); if (managedRecord) { record.managedSkillId = managedRecord.skillId; record.relatedCopies = []; + record.source = { + ...record.source, + ...(managedRecord.source.url + ? { url: managedRecord.source.url } + : {}), + ...(managedRecord.source.revision + ? { revision: managedRecord.source.revision } + : {}), + }; + events.push( + `record:${managedRecord.skillId}:${managedRecord.source.url ?? ""}:${managedRecord.source.revision ?? ""}`, + ); canonicalBySkillId.set(managedRecord.skillId, record); } } - const events: string[] = []; for (const index of indexes) { if (index.catalogId === "managed") { continue; diff --git a/src/internal/tree-fingerprint.ts b/src/internal/tree-fingerprint.ts new file mode 100644 index 0000000..47f9459 --- /dev/null +++ b/src/internal/tree-fingerprint.ts @@ -0,0 +1,233 @@ +import { + lstat, + readFile, + readdir, + realpath, + stat, +} from "node:fs/promises"; +import path from "node:path"; +import { isPathInside, sha256 } from "./util.js"; + +const MAX_FILES = 10_000; +const MAX_TOTAL_BYTES = 256 * 1024 * 1024; +const WINDOWS_RESERVED_NAMES = new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9", +]); + +export type TreeFingerprintFailure = + | "root-unavailable" + | "unsafe-root" + | "unsafe-path" + | "path-collision" + | "linked-tree" + | "tree-escape" + | "special-file" + | "tree-too-large" + | "tree-changed"; + +export class TreeFingerprintError extends Error { + readonly failure: TreeFingerprintFailure; + readonly relativePath: string | undefined; + readonly detail: unknown; + + constructor( + failure: TreeFingerprintFailure, + message: string, + relativePath?: string, + detail?: unknown, + ) { + super(message); + this.name = "TreeFingerprintError"; + this.failure = failure; + this.relativePath = relativePath; + this.detail = detail; + } +} + +export interface TreeFingerprintEntry { + kind: "directory" | "file"; + relativePath: string; + size?: number; + contentHash?: string; +} + +export interface TreeFingerprint { + root: string; + treeHash: string; + entries: TreeFingerprintEntry[]; + captured: ReadonlyMap; +} + +function validatePortableSegment(segment: string): void { + if ( + !segment || + /[. ]$/u.test(segment) || + /[<>:"/\\|?*\u0000-\u001F\u007F]/u.test(segment) + ) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is not portable: "${segment}".`, + segment, + ); + } + const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; + if (WINDOWS_RESERVED_NAMES.has(base)) { + throw new TreeFingerprintError( + "unsafe-path", + `Skill path segment is reserved on Windows: "${segment}".`, + segment, + ); + } +} + +function portablePathKey(relativePath: string): string { + return relativePath.normalize("NFKC").toLocaleLowerCase("und"); +} + +export async function fingerprintTree( + sourceRoot: string, + capturePaths: ReadonlySet = new Set(), +): Promise { + const rootInput = path.resolve(sourceRoot); + let rootInfo; + try { + rootInfo = await lstat(rootInput); + } catch (error) { + throw new TreeFingerprintError( + "root-unavailable", + `Tree root is unavailable: "${rootInput}".`, + undefined, + error, + ); + } + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new TreeFingerprintError( + "unsafe-root", + `Tree root must be a real directory: "${rootInput}".`, + ); + } + const root = await realpath(rootInput); + const entries: TreeFingerprintEntry[] = []; + const captured = new Map(); + const pathKeys = new Set(); + let fileCount = 0; + let totalBytes = 0; + + async function walk( + directory: string, + relativeDirectory: string, + ): Promise { + const children = await readdir(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name, "en")); + for (const child of children) { + if (relativeDirectory === "" && child.name === ".git") { + continue; + } + validatePortableSegment(child.name); + const relativePath = relativeDirectory + ? `${relativeDirectory}/${child.name}` + : child.name; + const key = portablePathKey(relativePath); + if (pathKeys.has(key)) { + throw new TreeFingerprintError( + "path-collision", + `Case-insensitive path collision at "${relativePath}".`, + relativePath, + ); + } + pathKeys.add(key); + const childPath = path.join(directory, child.name); + const before = await lstat(childPath); + if (before.isSymbolicLink()) { + throw new TreeFingerprintError( + "linked-tree", + `Links are not allowed in a skill tree: "${relativePath}".`, + relativePath, + ); + } + if (before.isDirectory()) { + const canonical = await realpath(childPath); + if (!isPathInside(root, canonical)) { + throw new TreeFingerprintError( + "tree-escape", + `Directory escapes the skill root: "${relativePath}".`, + relativePath, + ); + } + entries.push({ kind: "directory", relativePath }); + await walk(childPath, relativePath); + continue; + } + if (!before.isFile()) { + throw new TreeFingerprintError( + "special-file", + `Only regular files and directories are allowed: "${relativePath}".`, + relativePath, + ); + } + fileCount += 1; + totalBytes += before.size; + if (fileCount > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { + throw new TreeFingerprintError( + "tree-too-large", + `Skill exceeds ${MAX_FILES} files or ${MAX_TOTAL_BYTES} bytes.`, + relativePath, + ); + } + const content = await readFile(childPath); + const after = await stat(childPath); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new TreeFingerprintError( + "tree-changed", + `Skill changed while it was being read: "${relativePath}".`, + relativePath, + ); + } + entries.push({ + kind: "file", + relativePath, + size: content.length, + contentHash: sha256(content), + }); + if (capturePaths.has(relativePath)) { + captured.set(relativePath, content); + } + } + } + + await walk(root, ""); + const fingerprint = entries + .map((entry) => + entry.kind === "directory" + ? `D\0${entry.relativePath}` + : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}`, + ) + .join("\n"); + return { + root, + treeHash: sha256(fingerprint), + entries, + captured, + }; +} diff --git a/src/internal/util.ts b/src/internal/util.ts index 2105985..497c8a3 100644 --- a/src/internal/util.ts +++ b/src/internal/util.ts @@ -125,6 +125,13 @@ export function isPathInside(root: string, candidate: string): boolean { ); } +export function pathIdentity(value: string): string { + const normalized = path.resolve(value).normalize("NFKC"); + return platform() === "win32" + ? normalized.toLocaleLowerCase("und") + : normalized; +} + export function normalizeRelativePath(value: string): string | undefined { if (!value || path.isAbsolute(value)) { return undefined; diff --git a/src/stash-lifecycle.ts b/src/stash-lifecycle.ts index f9016b1..5572be9 100644 --- a/src/stash-lifecycle.ts +++ b/src/stash-lifecycle.ts @@ -7,21 +7,19 @@ import { realpath, rename, rm, - stat, unlink, writeFile, } from "node:fs/promises"; -import { homedir, platform } from "node:os"; +import { homedir } from "node:os"; import path from "node:path"; import { parse } from "yaml"; import type { - CreateStashCatalogOptions, + CreateStashLifecycleOptions, LifecycleActivateRequest, LifecycleArchiveRequest, LifecycleDeactivateRequest, LifecycleDeployment, LifecycleHost, - LifecycleHostTarget, LifecycleInstallRequest, LifecycleMutationResult, LifecycleScope, @@ -34,52 +32,30 @@ import type { } from "./types.js"; import { StashError } from "./types.js"; import { loadConfiguration } from "./internal/configuration.js"; +import { + lifecycleRefreshObservation, + lifecycleReloadRequired, + resolveLifecycleTarget, +} from "./internal/lifecycle-host-policy.js"; +import { + fingerprintTree, + TreeFingerprintError, + type TreeFingerprintEntry, +} from "./internal/tree-fingerprint.js"; import { isPathInside, + pathIdentity, platformManagedPath, sha256, } from "./internal/util.js"; const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -const MAX_FILES = 10_000; -const MAX_TOTAL_BYTES = 256 * 1024 * 1024; const STORE_SCHEMA_VERSION = 1 as const; -const WINDOWS_RESERVED_NAMES = new Set([ - "con", - "prn", - "aux", - "nul", - "com1", - "com2", - "com3", - "com4", - "com5", - "com6", - "com7", - "com8", - "com9", - "lpt1", - "lpt2", - "lpt3", - "lpt4", - "lpt5", - "lpt6", - "lpt7", - "lpt8", - "lpt9", -]); - -interface TreeEntry { - kind: "directory" | "file"; - relativePath: string; - size?: number; - contentHash?: string; -} interface TreeSnapshot { root: string; treeHash: string; - entries: TreeEntry[]; + entries: TreeFingerprintEntry[]; skillSource: string; sidecarSource?: string; } @@ -188,153 +164,47 @@ function parseSkillMetadata(snapshot: TreeSnapshot): { }; } -function portablePathKey(relativePath: string): string { - return relativePath.normalize("NFKC").toLocaleLowerCase("und"); -} - -function validatePortableSegment(segment: string): void { - if (!segment || /[. ]$/u.test(segment)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot end in a dot or space: "${segment}".`, - 3, - ); - } - const base = segment.split(".", 1)[0]?.toLocaleLowerCase("und") ?? ""; - if (WINDOWS_RESERVED_NAMES.has(base)) { - throw new StashError( - "unsafe-skill-tree", - `Portable skill paths cannot use the reserved name "${segment}".`, - 3, - ); - } -} - async function snapshotTree(sourceRoot: string): Promise { - const rootInput = path.resolve(sourceRoot); - const rootInfo = await lstat(rootInput).catch((error: unknown) => { - throw new StashError( - "skill-unavailable", - `Skill directory is unavailable at "${rootInput}": ${String(error)}`, - 4, - ); - }); - if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { - throw new StashError( - "unsafe-skill-tree", - `Skill root must be a real directory, not a link: "${rootInput}".`, - 3, + let fingerprint; + try { + fingerprint = await fingerprintTree( + sourceRoot, + new Set(["SKILL.md", "stash.meta.yaml"]), ); - } - const root = await realpath(rootInput); - const entries: TreeEntry[] = []; - const pathKeys = new Set(); - let fileCount = 0; - let totalBytes = 0; - let skillSource: string | undefined; - let sidecarSource: string | undefined; - - async function walk(directory: string, relativeDirectory: string): Promise { - const children = await readdir(directory, { withFileTypes: true }); - children.sort((left, right) => left.name.localeCompare(right.name, "en")); - for (const child of children) { - if (relativeDirectory === "" && child.name === ".git") { - continue; - } - validatePortableSegment(child.name); - const relativePath = relativeDirectory - ? `${relativeDirectory}/${child.name}` - : child.name; - const key = portablePathKey(relativePath); - if (pathKeys.has(key)) { - throw new StashError( - "unsafe-skill-tree", - `Case-insensitive path collision at "${relativePath}".`, - 3, - ); - } - pathKeys.add(key); - const childPath = path.join(directory, child.name); - const before = await lstat(childPath); - if (before.isSymbolicLink()) { - throw new StashError( - "unsafe-skill-tree", - `Symlinks and junctions are not allowed: "${relativePath}".`, - 3, - ); - } - if (before.isDirectory()) { - const canonical = await realpath(childPath); - if (!isPathInside(root, canonical)) { - throw new StashError( - "unsafe-skill-tree", - `Directory escapes the skill root: "${relativePath}".`, - 3, - ); - } - entries.push({ kind: "directory", relativePath }); - await walk(childPath, relativePath); - continue; - } - if (!before.isFile()) { - throw new StashError( - "unsafe-skill-tree", - `Only regular files and directories are allowed: "${relativePath}".`, - 3, - ); - } - fileCount += 1; - totalBytes += before.size; - if (fileCount > MAX_FILES || totalBytes > MAX_TOTAL_BYTES) { - throw new StashError( - "skill-too-large", - `Skill exceeds ${MAX_FILES} files or ${MAX_TOTAL_BYTES} bytes.`, - 3, - ); - } - const content = await readFile(childPath); - const after = await stat(childPath); - if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { - throw new StashError( - "source-changed", - `Skill changed while it was being read: "${relativePath}".`, - 4, - ); - } - const contentHash = sha256(content); - entries.push({ - kind: "file", - relativePath, - size: content.length, - contentHash, - }); - if (relativePath === "SKILL.md") { - skillSource = content.toString("utf8"); - } else if (relativePath === "stash.meta.yaml") { - sidecarSource = content.toString("utf8"); - } + } catch (error) { + if (!(error instanceof TreeFingerprintError)) { + throw error; } + if (error.failure === "root-unavailable") { + throw new StashError( + "skill-unavailable", + `Skill directory is unavailable at "${path.resolve(sourceRoot)}": ${String(error.detail ?? error.message)}`, + 4, + ); + } + if (error.failure === "tree-too-large") { + throw new StashError("skill-too-large", error.message, 3); + } + if (error.failure === "tree-changed") { + throw new StashError("source-changed", error.message, 4); + } + throw new StashError("unsafe-skill-tree", error.message, 3); } - - await walk(root, ""); + const skillSource = fingerprint.captured.get("SKILL.md")?.toString("utf8"); if (skillSource === undefined) { throw new StashError( "invalid-skill", - `Skill root must contain SKILL.md: "${root}".`, + `Skill root must contain SKILL.md: "${fingerprint.root}".`, 3, ); } - const fingerprint = entries - .map((entry) => - entry.kind === "directory" - ? `D\0${entry.relativePath}` - : `F\0${entry.relativePath}\0${entry.size}\0${entry.contentHash}`, - ) - .join("\n"); + const sidecarSource = fingerprint.captured + .get("stash.meta.yaml") + ?.toString("utf8"); return { - root, - treeHash: sha256(fingerprint), - entries, + root: fingerprint.root, + treeHash: fingerprint.treeHash, + entries: fingerprint.entries, skillSource, ...(sidecarSource !== undefined ? { sidecarSource } : {}), }; @@ -388,19 +258,7 @@ async function pathType(target: string): Promise<"missing" | "directory" | "link } function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left).normalize("NFKC"); - const normalizedRight = path.resolve(right).normalize("NFKC"); - return platform() === "win32" - ? normalizedLeft.toLocaleLowerCase("und") === - normalizedRight.toLocaleLowerCase("und") - : normalizedLeft === normalizedRight; -} - -function pathIdentity(value: string): string { - const normalized = path.resolve(value).normalize("NFKC"); - return platform() === "win32" - ? normalized.toLocaleLowerCase("und") - : normalized; + return pathIdentity(left) === pathIdentity(right); } function targetIdentity(target: { @@ -411,62 +269,6 @@ function targetIdentity(target: { return `${target.host}:${target.scope}:${pathIdentity(target.root)}`; } -function resolveHostRoot(target: LifecycleHostTarget): { - host: LifecycleHost; - scope: LifecycleScope; - root: string; -} { - if (target.host === "antigravity-cli") { - throw new StashError( - "unsupported-host-layout", - "Antigravity CLI standalone skills use flat Markdown in both user and workspace scopes; folder lifecycle is unsupported.", - 2, - ); - } - if (target.scope === "workspace") { - throw new StashError( - "unsupported-host-scope", - "Workspace lifecycle targets are not supported in this release.", - 2, - ); - } - if (target.root) { - return { - host: target.host, - scope: target.scope ?? "custom", - root: path.resolve(target.root), - }; - } - const scope = target.scope ?? "user"; - if (scope === "custom") { - throw new StashError( - "invalid-argument", - "A custom lifecycle target requires root.", - 2, - ); - } - switch (target.host) { - case "codex": - return { - host: target.host, - scope, - root: path.join(homedir(), ".agents", "skills"), - }; - case "claude-code": - return { - host: target.host, - scope, - root: path.join(homedir(), ".claude", "skills"), - }; - case "antigravity-ide": - return { - host: target.host, - scope, - root: path.join(homedir(), ".gemini", "config", "skills"), - }; - } -} - async function isPluginContained(source: string): Promise { let current = path.dirname(source); for (let depth = 0; depth < 12; depth += 1) { @@ -492,6 +294,7 @@ async function isPluginContained(source: string): Promise { class StashLifecycleImplementation implements StashLifecycle { readonly #managedRoot: string; readonly #now: () => number; + readonly #lifecycleHome = path.resolve(homedir()); constructor(managedRoot: string, now: () => number) { this.#managedRoot = path.resolve(managedRoot); @@ -749,12 +552,16 @@ class StashLifecycleImplementation implements StashLifecycle { return owner; } - #ownerIsAlive(owner: LifecycleLockOwner): boolean { + #ownerState(owner: LifecycleLockOwner): "alive" | "dead" | "unknown" { try { process.kill(owner.pid, 0); - return true; - } catch { - return false; + return "alive"; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + return code === "ESRCH" ? "dead" : "unknown"; } } @@ -815,10 +622,10 @@ class StashLifecycleImplementation implements StashLifecycle { ); } const owner = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(owner)) { + if (this.#ownerState(owner) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4, ); } @@ -855,10 +662,10 @@ class StashLifecycleImplementation implements StashLifecycle { return owner; } const existing = await this.#readLockOwner(lockPath); - if (this.#ownerIsAlive(existing)) { + if (this.#ownerState(existing) !== "dead") { throw new StashError( "lifecycle-busy", - `Another lifecycle operation holds "${lockPath}".`, + `Lifecycle ownership at "${lockPath}" is live or cannot be safely probed.`, 4, ); } @@ -1117,7 +924,10 @@ class StashLifecycleImplementation implements StashLifecycle { request: LifecycleArchiveRequest, ): Promise { return this.#withLock(async () => { - const resolvedTarget = resolveHostRoot(request.target); + const resolvedTarget = resolveLifecycleTarget( + request.target, + this.#lifecycleHome, + ); resolvedTarget.root = await this.#canonicalHostRoot( resolvedTarget.root, false, @@ -1146,9 +956,42 @@ class StashLifecycleImplementation implements StashLifecycle { const sourceSnapshot = await snapshotTree(source); const metadata = parseSkillMetadata(sourceSnapshot); const managedPath = path.join(this.#managedRoot, metadata.name); + const existingRecord = await this.#readRecord(metadata.name); + const managedType = await pathType(managedPath); + const selectedTargetId = targetIdentity(resolvedTarget); + const trackedDeployment = existingRecord?.deployments.find( + (deployment) => + samePath(deployment.path, source) && + deployment.targetId === selectedTargetId, + ); + if (existingRecord && trackedDeployment) { + if (managedType !== "directory") { + throw new StashError( + "managed-drift", + `Managed skill "${metadata.name}" is unavailable; refusing to archive its tracked deployment.`, + 3, + ); + } + const managedSnapshot = await snapshotTree(managedPath); + if ( + managedSnapshot.treeHash !== existingRecord.treeHash || + sourceSnapshot.treeHash !== existingRecord.treeHash || + trackedDeployment.treeHash !== existingRecord.treeHash + ) { + throw new StashError( + "managed-drift", + `Managed skill or tracked deployment "${metadata.name}" drifted; refusing archive.`, + 3, + ); + } + return this.#deactivateDeployment( + existingRecord, + managedPath, + resolvedTarget, + ); + } const managedExistedBefore = - (await pathType(managedPath)) !== "missing" || - (await this.#readRecord(metadata.name)) !== undefined; + managedType !== "missing" || existingRecord !== undefined; const tombstoneParent = path.dirname(resolvedTarget.root); await mkdir(tombstoneParent, { recursive: true }); const tombstone = path.join( @@ -1254,7 +1097,7 @@ class StashLifecycleImplementation implements StashLifecycle { 3, ); } - const target = resolveHostRoot(request.target); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); target.root = await this.#canonicalHostRoot(target.root, true); const deploymentPath = path.join(target.root, request.name); const targetId = targetIdentity(target); @@ -1275,7 +1118,7 @@ class StashLifecycleImplementation implements StashLifecycle { managedPath, treeHash: record.treeHash, deployment: tracked, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed.", }; @@ -1340,7 +1183,7 @@ class StashLifecycleImplementation implements StashLifecycle { managedPath, treeHash: record.treeHash, deployment, - reloadRequired: target.host !== "claude-code", + reloadRequired: lifecycleReloadRequired(target.host), warning: "DEPLOYED means present in a discovery root; host enable/disable overrides were not changed.", }; @@ -1352,6 +1195,110 @@ class StashLifecycleImplementation implements StashLifecycle { }); } + async #deactivateDeployment( + record: ManagedSkillRecord, + managedPath: string, + target: { host: LifecycleHost; scope: LifecycleScope; root: string }, + ): Promise { + const deploymentPath = path.join(target.root, record.name); + const targetId = targetIdentity(target); + const deployment = record.deployments.find( + (candidate) => + samePath(candidate.path, deploymentPath) && + candidate.targetId === targetId, + ); + if ( + !deployment || + deployment.ownership !== "stash" || + deployment.skillId !== record.skillId + ) { + throw new StashError( + "detached-deployment", + `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, + 3, + ); + } + const existing = await pathType(deploymentPath); + if (existing === "missing") { + record.deployments = record.deployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath), + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + await this.#writeRecord(record); + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + warning: "The tracked deployment was already missing.", + }; + } + if (existing !== "directory") { + throw new StashError( + "deployment-drift", + `Tracked deployment is no longer a real directory: "${deploymentPath}".`, + 3, + ); + } + const deployedSnapshot = await snapshotTree(deploymentPath); + if (deployedSnapshot.treeHash !== deployment.treeHash) { + throw new StashError( + "deployment-drift", + `Tracked deployment changed and will not be removed: "${deploymentPath}".`, + 3, + ); + } + const tombstone = path.join( + path.dirname(target.root), + `.stash-deactivate-${record.name}-${randomUUID()}`, + ); + await rename(deploymentPath, tombstone); + try { + const movedSnapshot = await snapshotTree(tombstone); + if (movedSnapshot.treeHash !== deployment.treeHash) { + await rename(tombstone, deploymentPath); + throw new StashError( + "deployment-drift", + `Deployment changed during deactivation and was restored: "${deploymentPath}".`, + 3, + ); + } + const previousDeployments = record.deployments; + record.deployments = previousDeployments.filter( + (candidate) => !samePath(candidate.path, deploymentPath), + ); + record.lastValidatedAt = new Date(this.#now()).toISOString(); + try { + await this.#writeRecord(record); + } catch (error) { + record.deployments = previousDeployments; + await rename(tombstone, deploymentPath).catch(() => undefined); + throw error; + } + let warning: string | undefined; + try { + await rm(tombstone, { recursive: true, force: false }); + } catch (error) { + warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; + } + return { + status: "deactivated", + name: record.name, + skillId: record.skillId, + managedPath, + treeHash: record.treeHash, + reloadRequired: lifecycleReloadRequired(target.host), + ...(warning ? { warning } : {}), + }; + } catch (error) { + if ((await pathType(tombstone)) !== "missing") { + await rename(tombstone, deploymentPath).catch(() => undefined); + } + throw error; + } + } + async deactivate( request: LifecycleDeactivateRequest, ): Promise { @@ -1365,105 +1312,9 @@ class StashLifecycleImplementation implements StashLifecycle { ); } const managedPath = path.join(this.#managedRoot, request.name); - const target = resolveHostRoot(request.target); + const target = resolveLifecycleTarget(request.target, this.#lifecycleHome); target.root = await this.#canonicalHostRoot(target.root, false, true); - const deploymentPath = path.join(target.root, request.name); - const targetId = targetIdentity(target); - const deployment = record.deployments.find( - (candidate) => - samePath(candidate.path, deploymentPath) && - candidate.targetId === targetId, - ); - if ( - !deployment || - deployment.ownership !== "stash" || - deployment.skillId !== record.skillId - ) { - throw new StashError( - "detached-deployment", - `Stash did not create deployment "${deploymentPath}"; refusing to remove it.`, - 3, - ); - } - const existing = await pathType(deploymentPath); - if (existing === "missing") { - record.deployments = record.deployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath), - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - await this.#writeRecord(record); - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - warning: "The tracked deployment was already missing.", - }; - } - if (existing !== "directory") { - throw new StashError( - "deployment-drift", - `Tracked deployment is no longer a real directory: "${deploymentPath}".`, - 3, - ); - } - const deployedSnapshot = await snapshotTree(deploymentPath); - if (deployedSnapshot.treeHash !== deployment.treeHash) { - throw new StashError( - "deployment-drift", - `Tracked deployment changed and will not be removed: "${deploymentPath}".`, - 3, - ); - } - const tombstone = path.join( - path.dirname(target.root), - `.stash-deactivate-${record.name}-${randomUUID()}`, - ); - await rename(deploymentPath, tombstone); - try { - const movedSnapshot = await snapshotTree(tombstone); - if (movedSnapshot.treeHash !== deployment.treeHash) { - await rename(tombstone, deploymentPath); - throw new StashError( - "deployment-drift", - `Deployment changed during deactivation and was restored: "${deploymentPath}".`, - 3, - ); - } - const previousDeployments = record.deployments; - record.deployments = previousDeployments.filter( - (candidate) => !samePath(candidate.path, deploymentPath), - ); - record.lastValidatedAt = new Date(this.#now()).toISOString(); - try { - await this.#writeRecord(record); - } catch (error) { - record.deployments = previousDeployments; - await rename(tombstone, deploymentPath).catch(() => undefined); - throw error; - } - let warning: string | undefined; - try { - await rm(tombstone, { recursive: true, force: false }); - } catch (error) { - warning = `Deployment left discovery, but cleanup remains at "${tombstone}": ${String(error)}`; - } - return { - status: "deactivated", - name: record.name, - skillId: record.skillId, - managedPath, - treeHash: record.treeHash, - reloadRequired: target.host !== "claude-code", - ...(warning ? { warning } : {}), - }; - } catch (error) { - if ((await pathType(tombstone)) !== "missing") { - await rename(tombstone, deploymentPath).catch(() => undefined); - } - throw error; - } + return this.#deactivateDeployment(record, managedPath, target); }); } @@ -1526,10 +1377,7 @@ class StashLifecycleImplementation implements StashLifecycle { hostObservation: { override: "unknown", discovery: "absent", - refresh: - deployment.host === "claude-code" - ? "live" - : "restart-required", + refresh: lifecycleRefreshObservation(deployment.host), }, }); continue; @@ -1542,10 +1390,7 @@ class StashLifecycleImplementation implements StashLifecycle { hostObservation: { override: "unknown", discovery: "unknown", - refresh: - deployment.host === "claude-code" - ? "live" - : "restart-required", + refresh: lifecycleRefreshObservation(deployment.host), }, }); continue; @@ -1562,10 +1407,7 @@ class StashLifecycleImplementation implements StashLifecycle { hostObservation: { override: "unknown", discovery: "present", - refresh: - deployment.host === "claude-code" - ? "live" - : "restart-required", + refresh: lifecycleRefreshObservation(deployment.host), }, }); } catch { @@ -1576,10 +1418,7 @@ class StashLifecycleImplementation implements StashLifecycle { hostObservation: { override: "unknown", discovery: "unknown", - refresh: - deployment.host === "claude-code" - ? "live" - : "restart-required", + refresh: lifecycleRefreshObservation(deployment.host), }, }); } @@ -1607,9 +1446,18 @@ class StashLifecycleImplementation implements StashLifecycle { } export async function createStashLifecycle( - options: CreateStashCatalogOptions = {}, + options: CreateStashLifecycleOptions = {}, ): Promise { - const loaded = await loadConfiguration(options); + const effectiveOptions = + options.catalogs && !options.managedRoot + ? { + ...options, + managedRoot: path.resolve( + process.env.STASH_MANAGED_HOME ?? platformManagedPath(), + ), + } + : options; + const loaded = await loadConfiguration(effectiveOptions); const managedRoot = path.resolve( loaded.configuration.managedRoot ?? options.managedRoot ?? diff --git a/src/types.ts b/src/types.ts index ce1354f..33869e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,7 @@ export type LifecycleHost = | "claude-code" | "antigravity-ide" | "antigravity-cli"; -export type LifecycleScope = "user" | "workspace" | "custom"; +export type LifecycleScope = "user" | "workspace"; export type CompatibilityState = "supported" | "partial" | "unsupported" | "unknown"; export type TrustState = "trusted" | "reviewed" | "unreviewed" | "quarantined"; export type RelevanceTier = "exact" | "strong" | "material" | "possible"; @@ -115,6 +115,8 @@ export interface CreateStashCatalogOptions { now?: () => number; } +export type CreateStashLifecycleOptions = CreateStashCatalogOptions; + export interface ResolveFilters { catalogIds?: string[]; sources?: string[]; @@ -296,7 +298,6 @@ export interface ManagedSkillRecord { export interface LifecycleHostTarget { host: LifecycleHost; scope?: LifecycleScope; - root?: string; workspace?: string; } diff --git a/tests-dist/cli.test.mjs b/tests-dist/cli.test.mjs index 9d6f72e..af8d3bb 100644 --- a/tests-dist/cli.test.mjs +++ b/tests-dist/cli.test.mjs @@ -66,7 +66,13 @@ test("bundled skill CLI installs, resolves, deploys, and deactivates a managed s const temp = await mkdtemp(path.join(tmpdir(), "stash-lifecycle-dist-test-")); const source = path.join(temp, "source", "rare-skill"); const managedRoot = path.join(temp, "managed"); - const hostRoot = path.join(temp, "host", "skills"); + const sandboxHome = path.join(temp, "home"); + const hostRoot = path.join(sandboxHome, ".agents", "skills"); + const cliEnvironment = { + ...process.env, + HOME: sandboxHome, + USERPROFILE: sandboxHome, + }; await mkdir(source, { recursive: true }); await writeFile( path.join(source, "SKILL.md"), @@ -81,7 +87,7 @@ test("bundled skill CLI installs, resolves, deploys, and deactivates a managed s "install", source, ...common, - ]) + ], { env: cliEnvironment }) ).stdout, ); assert.equal(installed.status, "stored"); @@ -93,7 +99,7 @@ test("bundled skill CLI installs, resolves, deploys, and deactivates a managed s "exact", "rare-skill", ...common, - ]) + ], { env: cliEnvironment }) ).stdout, ); assert.equal(resolved.status, "ok"); @@ -108,11 +114,9 @@ test("bundled skill CLI installs, resolves, deploys, and deactivates a managed s "--host", "codex", "--scope", - "custom", - "--host-root", - hostRoot, + "user", ...common, - ]) + ], { env: cliEnvironment }) ).stdout, ); assert.equal(deployed.status, "deployed"); @@ -127,11 +131,9 @@ test("bundled skill CLI installs, resolves, deploys, and deactivates a managed s "--host", "codex", "--scope", - "custom", - "--host-root", - hostRoot, + "user", ...common, - ]) + ], { env: cliEnvironment }) ).stdout, ); assert.equal(deactivated.status, "deactivated"); diff --git a/tests/stash-lifecycle.test.ts b/tests/stash-lifecycle.test.ts index 6f3c31e..ff9855b 100644 --- a/tests/stash-lifecycle.test.ts +++ b/tests/stash-lifecycle.test.ts @@ -13,9 +13,41 @@ import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { createStashCatalog } from "../src/stash-catalog.js"; -import { createStashLifecycle } from "../src/stash-lifecycle.js"; +import { + lifecycleRefreshObservation, + lifecycleReloadRequired, + resolveLifecycleTarget, +} from "../src/internal/lifecycle-host-policy.js"; +import { + createStashLifecycle as createStashLifecycleForCurrentHome, +} from "../src/stash-lifecycle.js"; +import type { CreateStashLifecycleOptions } from "../src/types.js"; import { StashError } from "../src/types.js"; +async function createStashLifecycle( + options: CreateStashLifecycleOptions & { lifecycleHome: string }, +) { + const { lifecycleHome, ...publicOptions } = options; + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + process.env.HOME = lifecycleHome; + process.env.USERPROFILE = lifecycleHome; + try { + return await createStashLifecycleForCurrentHome(publicOptions); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + } +} + async function createStandaloneSkill( parent: string, name: string, @@ -51,11 +83,17 @@ async function lifecycleFixture(): Promise<{ test("install creates a searchable inactive canonical copy without changing source", async () => { const fixture = await lifecycleFixture(); + const sourceUrl = "https://github.com/example/rare-skills"; const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + const installed = await lifecycle.install({ + source: fixture.sourceRoot, + sourceUrl, + revision: "abc123", }); - const installed = await lifecycle.install({ source: fixture.sourceRoot }); assert.equal(installed.status, "stored"); assert.match(installed.skillId, /^[0-9a-f-]{36}$/u); await access(path.join(fixture.sourceRoot, "SKILL.md")); @@ -72,23 +110,86 @@ test("install creates a searchable inactive canonical copy without changing sour const resolved = await catalog.resolve({ kind: "exact", name: "rare-skill" }); assert.equal(resolved.status, "ok"); assert.equal(resolved.matches[0]?.catalogId, "managed"); + const sourceScoped = await catalog.resolve({ + kind: "exact", + name: "rare-skill", + sources: [sourceUrl], + }); + assert.equal(sourceScoped.status, "ok"); + assert.equal(sourceScoped.matches[0]?.source.url, sourceUrl); + assert.equal(sourceScoped.matches[0]?.source.revision, "abc123"); const repeated = await lifecycle.install({ source: fixture.sourceRoot }); assert.equal(repeated.status, "already-stored"); }); +test("host policy pins documented user roots and refresh behavior", () => { + const home = path.resolve("fixture-home"); + assert.deepEqual(resolveLifecycleTarget({ host: "codex" }, home), { + host: "codex", + scope: "user", + root: path.join(home, ".agents", "skills"), + }); + assert.deepEqual(resolveLifecycleTarget({ host: "claude-code" }, home), { + host: "claude-code", + scope: "user", + root: path.join(home, ".claude", "skills"), + }); + assert.deepEqual(resolveLifecycleTarget({ host: "antigravity-ide" }, home), { + host: "antigravity-ide", + scope: "user", + root: path.join(home, ".gemini", "config", "skills"), + }); + assert.equal(lifecycleReloadRequired("codex"), true); + assert.equal(lifecycleReloadRequired("claude-code"), false); + assert.equal(lifecycleReloadRequired("antigravity-ide"), true); + assert.equal(lifecycleRefreshObservation("codex"), "restart-required"); + assert.equal(lifecycleRefreshObservation("claude-code"), "live"); +}); + +test("default managed storage cannot overlap explicit catalogs", async () => { + const fixture = await lifecycleFixture(); + const catalogRoot = path.dirname(fixture.sourceRoot); + const previousManagedHome = process.env.STASH_MANAGED_HOME; + process.env.STASH_MANAGED_HOME = catalogRoot; + try { + await assert.rejects( + createStashLifecycle({ + catalogs: [ + { + id: "external", + root: catalogRoot, + enabled: true, + trust: "unreviewed", + followSymlinks: false, + }, + ], + lifecycleHome: path.join(fixture.base, "home"), + }), + (error: unknown) => + error instanceof StashError && error.code === "invalid-config", + ); + } finally { + if (previousManagedHome === undefined) { + delete process.env.STASH_MANAGED_HOME; + } else { + process.env.STASH_MANAGED_HOME = previousManagedHome; + } + } +}); + test("activate and deactivate only mutate a tracked verified deployment", async () => { const fixture = await lifecycleFixture(); - const hostRoot = path.join(fixture.base, "host-skills"); + const hostRoot = path.join(fixture.base, "home", ".agents", "skills"); const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await lifecycle.install({ source: fixture.sourceRoot }); const target = { host: "codex" as const, - scope: "custom" as const, - root: hostRoot, + scope: "user" as const, }; const activated = await lifecycle.activate({ name: "rare-skill", target }); assert.equal(activated.status, "deployed"); @@ -114,15 +215,16 @@ test("activate and deactivate only mutate a tracked verified deployment", async test("archive verifies a standalone skill before removing it from host discovery", async () => { const fixture = await lifecycleFixture(); - const hostRoot = path.join(fixture.base, "active-skills"); + const hostRoot = path.join(fixture.base, "home", ".agents", "skills"); const active = await createStandaloneSkill(hostRoot, "archive-me"); const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); const archived = await lifecycle.archive({ source: "archive-me", - target: { host: "codex", scope: "custom", root: hostRoot }, + target: { host: "codex", scope: "user" }, }); assert.equal(archived.status, "stored"); await assert.rejects(access(active)); @@ -135,17 +237,18 @@ test("archive verifies a standalone skill before removing it from host discovery test("deactivate refuses to remove an untracked detached directory", async () => { const fixture = await lifecycleFixture(); - const hostRoot = path.join(fixture.base, "detached-host"); + const hostRoot = path.join(fixture.base, "home", ".agents", "skills"); const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await lifecycle.install({ source: fixture.sourceRoot }); await createStandaloneSkill(hostRoot, "rare-skill"); await assert.rejects( lifecycle.deactivate({ name: "rare-skill", - target: { host: "codex", scope: "custom", root: hostRoot }, + target: { host: "codex", scope: "user" }, }), (error: unknown) => error instanceof StashError && error.code === "detached-deployment", @@ -170,6 +273,7 @@ test("install rejects symlinks or junctions anywhere in the skill tree", async ( const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await assert.rejects( lifecycle.install({ source: fixture.sourceRoot }), @@ -180,15 +284,16 @@ test("install rejects symlinks or junctions anywhere in the skill tree", async ( test("status reports stored and deployed state without claiming host activation", async () => { const fixture = await lifecycleFixture(); - const hostRoot = path.join(fixture.base, "status-host"); + const hostRoot = path.join(fixture.base, "home", ".claude", "skills"); const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await lifecycle.install({ source: fixture.sourceRoot }); await lifecycle.activate({ name: "rare-skill", - target: { host: "claude-code", scope: "custom", root: hostRoot }, + target: { host: "claude-code", scope: "user" }, }); const status = await lifecycle.status({ name: "rare-skill" }); assert.equal(status.status, "ok"); @@ -211,6 +316,7 @@ test("lifecycle rejects Antigravity CLI flat-file scopes", async () => { const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await lifecycle.install({ source: fixture.sourceRoot }); for (const scope of ["user", "workspace"] as const) { @@ -227,7 +333,7 @@ test("lifecycle rejects Antigravity CLI flat-file scopes", async () => { test("archive requires an exact child of the selected standalone host root", async () => { const fixture = await lifecycleFixture(); - const hostRoot = path.join(fixture.base, "selected-host"); + const hostRoot = path.join(fixture.base, "home", ".agents", "skills"); await mkdir(hostRoot, { recursive: true }); const elsewhere = await createStandaloneSkill( path.join(fixture.base, "elsewhere"), @@ -236,11 +342,12 @@ test("archive requires an exact child of the selected standalone host root", asy const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await assert.rejects( lifecycle.archive({ source: elsewhere, - target: { host: "codex", scope: "custom", root: hostRoot }, + target: { host: "codex", scope: "user" }, }), (error: unknown) => error instanceof StashError && error.code === "unsafe-archive-source", @@ -262,6 +369,7 @@ test("managed projection folds a preserved catalog source without hiding scoped }, ], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await lifecycle.install({ source: fixture.sourceRoot }); const catalog = await createStashCatalog({ @@ -311,7 +419,7 @@ test("managed projection folds a preserved catalog source without hiding scoped test("a configured host catalog supports archive to activate round trips", async () => { const fixture = await lifecycleFixture(); - const hostRoot = path.join(fixture.base, "configured-host"); + const hostRoot = path.join(fixture.base, "home", ".agents", "skills"); await createStandaloneSkill(hostRoot, "round-trip-skill"); const registration = { id: "host", @@ -323,14 +431,23 @@ test("a configured host catalog supports archive to activate round trips", async const lifecycle = await createStashLifecycle({ catalogs: [registration], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); const target = { host: "codex" as const, - scope: "custom" as const, - root: hostRoot, + scope: "user" as const, }; await lifecycle.archive({ source: "round-trip-skill", target }); await lifecycle.activate({ name: "round-trip-skill", target }); + const trackedArchive = await lifecycle.archive({ + source: "round-trip-skill", + target, + }); + assert.equal(trackedArchive.status, "deactivated"); + await assert.rejects(access(path.join(hostRoot, "round-trip-skill"))); + const inactive = await lifecycle.status({ name: "round-trip-skill" }); + assert.equal(inactive.skills[0]?.deployments.length, 0); + await lifecycle.activate({ name: "round-trip-skill", target }); const catalog = await createStashCatalog({ catalogs: [registration], @@ -355,11 +472,56 @@ test("a configured host catalog supports archive to activate round trips", async assert.equal(scoped.matches[0]?.catalogId, "host"); }); +test("managed projection rejects a malformed record before folding any copy", async () => { + const fixture = await lifecycleFixture(); + const catalogRoot = path.dirname(fixture.sourceRoot); + const registration = { + id: "external", + root: catalogRoot, + enabled: true, + trust: "unreviewed" as const, + followSymlinks: false, + }; + const lifecycle = await createStashLifecycle({ + catalogs: [registration], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + await lifecycle.install({ source: fixture.sourceRoot }); + const recordPath = path.join( + fixture.managedRoot, + ".stash", + "records", + "rare-skill.json", + ); + const record = JSON.parse(await readFile(recordPath, "utf8")) as Record< + string, + unknown + >; + record.deployments = [null]; + await writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8"); + + const catalog = await createStashCatalog({ + catalogs: [registration], + managedRoot: fixture.managedRoot, + cacheDir: path.join(fixture.base, "malformed-projection-cache"), + }); + const result = await catalog.resolve({ kind: "exact", name: "rare-skill" }); + assert.equal(result.status, "ambiguous-exact"); + assert.equal(result.matches.length, 2); + assert.ok( + result.diagnostics.warnings?.some( + (warning) => warning.code === "invalid-lifecycle-record", + ), + ); +}); + test("the next mutation deterministically restores an interrupted archive", async () => { const fixture = await lifecycleFixture(); const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); const installed = await lifecycle.install({ source: fixture.sourceRoot }); const tombstone = path.join( @@ -410,6 +572,7 @@ test("a dead-process lifecycle lock is recovered before the next mutation", asyn const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await lifecycle.install({ source: fixture.sourceRoot }); const lockPath = path.join( @@ -447,6 +610,7 @@ test("malformed or live lifecycle ownership fails closed", async () => { const lifecycle = await createStashLifecycle({ catalogs: [], managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), }); await lifecycle.install({ source: fixture.sourceRoot }); const lockPath = path.join( @@ -466,3 +630,120 @@ test("malformed or live lifecycle ownership fails closed", async () => { await access(path.join(fixture.sourceRoot, "SKILL.md")); } }); + +test("unknown PID probe errors fail closed without reclaiming ownership", async () => { + const fixture = await lifecycleFixture(); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + await lifecycle.install({ source: fixture.sourceRoot }); + const lockPath = path.join(fixture.managedRoot, ".stash", "lifecycle.lock"); + await mkdir(lockPath, { recursive: false }); + await writeFile( + path.join(lockPath, "owner.json"), + `${JSON.stringify({ + schemaVersion: 1, + ownerToken: "00000000-0000-4000-8000-000000000005", + pid: 2_147_483_646, + createdAt: new Date(0).toISOString(), + })}\n`, + "utf8", + ); + const originalKill = process.kill; + Object.defineProperty(process, "kill", { + configurable: true, + enumerable: true, + writable: true, + value: (() => { + throw Object.assign(new Error("probe denied"), { code: "EPERM" }); + }) as typeof process.kill, + }); + try { + await assert.rejects( + lifecycle.install({ source: fixture.sourceRoot }), + (error: unknown) => + error instanceof StashError && error.code === "lifecycle-busy", + ); + await access(path.join(lockPath, "owner.json")); + } finally { + Object.defineProperty(process, "kill", { + configurable: true, + enumerable: true, + writable: true, + value: originalKill, + }); + } +}); + +test("managed storage rejects equality, nesting, and canonical catalog aliases", async (t) => { + const base = await mkdtemp(path.join(tmpdir(), "stash-overlap-test-")); + const catalogRoot = path.join(base, "catalog"); + await mkdir(catalogRoot, { recursive: true }); + const registration = { + id: "external", + root: catalogRoot, + enabled: true, + trust: "unreviewed" as const, + followSymlinks: false, + }; + for (const managedRoot of [ + catalogRoot, + path.join(catalogRoot, "managed"), + ]) { + await assert.rejects( + createStashCatalog({ catalogs: [registration], managedRoot }), + (error: unknown) => + error instanceof StashError && error.code === "invalid-config", + ); + } + + const managedParent = path.join(base, "managed-parent"); + const nestedCatalog = path.join(managedParent, "external"); + await mkdir(nestedCatalog, { recursive: true }); + await assert.rejects( + createStashCatalog({ + catalogs: [{ ...registration, root: nestedCatalog }], + managedRoot: managedParent, + }), + (error: unknown) => + error instanceof StashError && error.code === "invalid-config", + ); + + const alias = path.join(base, "catalog-alias"); + try { + await symlink( + catalogRoot, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + } catch (error) { + t.diagnostic(`Canonical alias check skipped: ${String(error)}`); + return; + } + await assert.rejects( + createStashCatalog({ catalogs: [registration], managedRoot: alias }), + (error: unknown) => + error instanceof StashError && error.code === "invalid-config", + ); +}); + +test("install rejects filenames that are invalid on supported hosts", async (t) => { + if (process.platform === "win32") { + t.skip("Windows refuses to create the non-portable fixture name."); + return; + } + const fixture = await lifecycleFixture(); + await writeFile(path.join(fixture.sourceRoot, "bad:name.txt"), "bad\n", "utf8"); + const lifecycle = await createStashLifecycle({ + catalogs: [], + managedRoot: fixture.managedRoot, + lifecycleHome: path.join(fixture.base, "home"), + }); + await assert.rejects( + lifecycle.install({ source: fixture.sourceRoot }), + (error: unknown) => + error instanceof StashError && error.code === "unsafe-skill-tree", + ); +});