feat(#1451): wire gsd capability install/update/remove/list/disable/enable management CLI (#1457)

* feat(#1451): wire gsd capability install/update/remove/list/disable/enable CLI

ADR-1244 D5/D6: the management command was built as a library (capability-lifecycle.cjs
install/upgrade/remove + capability-ledger.cjs) across Phases 3-5 but never wired to a
user-facing command — gsd-tools.cjs 'capability' only handled state/set. This adds the
six subcommands, dispatching to the existing lifecycle/ledger:

- install <spec> [--integrity] [--scope global|project] [--yes] [--shared-file <rel>]…
- update [<id>|--all] [--scope] [--yes] [--shared-file]  (re-resolves recorded source)
- remove <id> [--purge-data] [--scope]  (first-party rejected)
- list [--json]  (first-party + overlay, both scopes, JSON array)
- disable|enable <id>  (activation-state alias of capability set --off/--on)

Scope→runtimeDir mapping matches capability-loader exactly (global=$GSD_HOME||home,
project=project root; caps at <root>/.gsd/capabilities/<id>, ledger at <root>/.gsd-capabilities.json).
Consent is non-interactive: --yes grants; without it an executable install aborts after
printing the disclosure and writes nothing. Best-effort reconcile before each mutation.

Tests: tests/capability-cli.test.cjs (20 behavioral, real resolver via local specs,
GSD_HOME-sandboxed) — install consent/block/usage matrix, list, update round-trip,
remove round-trip + first-party guard, disable/enable, unknown subcommand.
Docs: docs/reference/gsd-capability-command.md reconciled to the real surface
(ledger paths, --shared-file, consent model, disable mechanism, outdated marked planned);
docs/COMMANDS.md gains the gsd capability entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1451): resolve adversarial-review findings + root-cause the --raw silent-output bug

Adversarial-review (Codex) fixes:
- capReadStrict passes a malformed strict_known_registries value THROUGH so the trust gate
  fail-closes on it (was silently downgrading to permissive)
- installCapability/upgradeCapability gain an expectedId guard + first-party-id rejection
  (capability-lifecycle.cts): an overlay can't shadow a first-party id, and 'update <id>' can't
  act on a different id if the recorded source was retargeted
- capability update: prints the consent disclosure, exits non-zero on --all partial failure,
  no longer masks the resolved id
- capability remove: ledger-first ordering so an overlay is removable even if it shadows a
  first-party name; first-party guard only fires for ids not in the ledger
- gsd-capability-command.md: disable/enable doc corrected (registry-known ids; overlay toggle
  not yet wired through this path)

Silent-output bug (root cause, not waved off as pre-existing):
- captureStdoutSyncWrites buffered fd-1 output and DISCARDED it on the throw path — any --raw
  command that emitted a result/error envelope then threw (to set a non-zero exit) lost ALL of
  stdout. Now it flushes the captured buffer before re-throwing (exit code preserved).
- cmdCapabilitySet threw via process.exit() (bypassing the capture wrapper entirely); now throws
  ExitError so the wrapper flushes — matches the repo's no-process-exit architecture.
- Regression test: capability disable <unknown> --raw must emit the JSON error envelope on stdout.

Verified: capability suite 165/165, @file/json-errors/phase 183/183, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1451): address adversarial-review R2 — shared-file confinement, MCP no-clobber, config fail-closed

- confinedSharedFile(): realpath-confine every shared-config write/strip to the scope root (mirrors
  safeRmUnder), so a --shared-file whose parent is a symlink escaping the scope can't write outside it.
- mcpServers shared edits: never overwrite an UNOWNED entry — a name collision with the user's (or
  another capability's) server is skipped, so install/remove can't silently clobber user MCP config
  (hooks already append; the map-keyed mcpServers path was the gap).
- capReadStrict: a PRESENT-but-unparseable .planning/config.json now fails CLOSED (lockdown) instead
  of silently downgrading the strict_known_registries policy to permissive.
- Tests: symlink-escape shared-file writes nothing outside scope; colliding user mcpServers entry
  preserved; unparseable config blocks an external install. capability suite 83/83, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1451): address code-review — aborted-status robustness + coverage + project-scoped strict doc

- install/update: handle an 'aborted' result independently of the requiresConsent flag so it can never
  fall through to the generic 'blocked: unknown reason' arm (aborted always means consent-needed per
  the lifecycle contract; latent today, hardened for future status additions).
- Clarify capResolveScope comment (project scope === already-resolved cwd) and document that
  strict_known_registries is a PROJECT-scoped policy (read regardless of --scope; no machine-wide
  allowlist) in gsd-capability-command.md.
- Tests: update --all over an empty ledger returns an empty result set (exit 0); a flag value that
  looks like another flag (--integrity --scope) is rejected, not swallowed. CLI suite 33/33, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1451): FEATURES.md entry #147 + Added/Fixed changesets for the capability CLI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1451): backfill changeset PR number → #1457

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-19 12:02:36 -04:00
committed by GitHub
parent 1abebbf4fd
commit 34bc096ec2
9 changed files with 989 additions and 105 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1457
---
**`--raw` CLI commands no longer drop stdout on the error path** — a command that emitted a JSON result/error envelope and then exited non-zero previously lost all of stdout (the output-capture wrapper discarded its buffer when the command threw to set a non-zero exit); the buffer is now flushed before the error propagates. (#1457)

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1457
---
**`gsd capability` management command** — install, update, remove, list, disable, and enable GSD capabilities (first-party and third-party overlays) from a registry / git / npm / tarball / local source, wiring the ADR-1244 lifecycle (source resolver, ledger, consent + integrity trust gate) to a user-facing CLI. (#1457)

View File

@@ -1115,6 +1115,30 @@ Toggle which skills are surfaced — apply a profile, list, or disable a cluster
/gsd-surface reset # Restore install-time profile
```
### `gsd capability`
Manage GSD capabilities — first-party (shipped) and third-party overlays. CLI form `gsd capability <subcommand>` (slash form `gsd:capability` on slash-command runtimes). See the [`gsd capability` command reference](reference/gsd-capability-command.md) for the full contract, source-spec forms, and install layout.
| Subcommand | Description |
|------------|-------------|
| `install <spec> [--integrity …] [--scope global\|project] [--yes] [--shared-file <rel>]…` | Resolve, verify, consent-gate, and install a capability from a registry / git / npm / tarball / local source |
| `update [<id> \| --all] [--scope …] [--yes]` | Re-resolve a capability's recorded source and upgrade it (atomic stage-then-swap) |
| `remove <id> [--purge-data] [--scope …]` | Remove an installed overlay capability's files + marker-isolated shared edits (first-party cannot be removed here) |
| `list [--json]` | List first-party + installed overlay capabilities as a JSON array |
| `disable <id>` / `enable <id>` | Toggle a capability's activation state (same as `capability set <id> --off`/`--on`) |
| `state` / `set <id> …` | Inspect resolved capability state / set activation + per-hook gates |
```bash
gsd capability list --json # All capabilities as JSON
gsd capability install ./my-cap --scope project # Install a local capability into the project
gsd capability install npm:@org/gsd-cap-x@^1 --yes # Install from npm, granting executable-surface consent
gsd capability update my-cap # Upgrade from its recorded source
gsd capability disable my-cap # Turn it off without removing it
gsd capability remove my-cap # Remove the overlay capability
```
**Programmatic access:** `node gsd-tools.cjs capability <subcommand>` — see [CLI Tools Reference](CLI-TOOLS.md).
---
## Brownfield Commands

View File

@@ -169,6 +169,7 @@
- [v1.43.0 Features](#v1430-features)
- [MemPalace Memory Capability](#145-mempalace-memory-capability)
- [Spec-Phase Prohibition Probe](#146-spec-phase-prohibition-probe)
- [Capability Management Command](#147-capability-management-command)
---
@@ -3185,3 +3186,20 @@ The load-bearing wire is the `plan-phase` lift into `must_haves.prohibitions`, s
- REQ-PROHIB-07: A `test`-tier prohibition with a **machine-proven-fail-first**, genuinely-passing (non-vacuous) wired mechanical check (a `node --test` negative test OR a lint/AST rule) MUST dispose green and be satisfiable; a missing, un-provable, or non-passing check MUST hard-gate (flagged, non-green) in both interactive and autonomous modes. Fail-first is **machine-proven, not caller-attested** (#1279, ADR-550 D5d): before a clean pass greens, the producer independently runs the wired check against a known violation (the descriptor's `violationFixture`) and confirms it goes RED — a lint rule via the violating fixture, a node test via the violating subject injected through the `GSD_PROHIB_SUBJECT` convention; absent a violation source it fails closed, never falling back to attestation. (Enforcement half shipped #1259; deterministic descriptor auto-locate in #1278.)
**Reference:** [Prohibition Probe](../gsd-core/references/prohibition-probe.md)
### 147. Capability Management Command
**Command:** `gsd capability install | update | remove | list | disable | enable`
**Purpose:** The user-facing CLI for the ADR-1244 capability ecosystem — install, upgrade, remove, list, and toggle GSD capabilities (first-party and third-party overlays) from a registry / git / npm / tarball / local source. Wires the Phase-3/4 lifecycle library (source resolver, install ledger, trust gate) to a command users actually run.
**Behavior:**
- `install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…` — resolve (copy-only) → verify integrity / SHA pin → `engines.gsd` gate → disclose executable surfaces → consent (`--yes` grants; without it an executable install aborts after printing the disclosure and writes nothing) → validate → extract → record the ledger.
- `update [<id> | --all] [--scope] [--yes]` — re-resolve the capability's recorded source and upgrade via atomic stage-then-swap; re-consent when the executable set changed; `--all` reports a per-capability outcome and exits non-zero on any partial failure.
- `remove <id> [--purge-data] [--scope]` — strip the ledger-recorded files + marker-isolated shared edits; first-party capabilities are rejected (use the product uninstaller).
- `list [--json]` — first-party + installed overlay capabilities (both scopes) as a JSON array.
- `disable | enable <id>` — toggle activation state (equivalent to `gsd capability set <id> --off` / `--on`).
**Trust boundary:** install never executes capability code (copy-only staging); executable surfaces require explicit consent; sources are gated by the **project-scoped** `capabilities.strict_known_registries` policy (fail-closed on a malformed/unparseable value); every shared-config write/delete is realpath-confined to the scope root, and a name collision with a user's `mcpServers` entry is never clobbered.
**Reference:** [`gsd capability` command reference](reference/gsd-capability-command.md) · [ADR-1244](adr/1244-capability-ecosystem.md)

View File

@@ -3,9 +3,11 @@
> **Slash form:** `gsd:capability` (surfaced as a slash command on slash-command runtimes)
> **CLI form:** `gsd capability`
> **Canonical ADR:** [ADR-1244](../adr/1244-capability-ecosystem.md)
> **See also:** [Capability Manifest Reference](capability-manifest.md) · [How to develop a capability](../how-to/develop-a-capability.md)
> **See also:** [Capability Manifest Reference](capability-manifest.md) · [How to develop a capability](../how-to/develop-a-capability.md) · [The capability trust model](../explanation/capability-trust-model.md)
The `capability` family manages the installation, upgrade, removal, and inspection of GSD capabilities — both first-party and third-party overlays. A row for this command also appears in [docs/COMMANDS.md](../COMMANDS.md) (that file is not edited here).
The `capability` family manages the installation, upgrade, removal, and inspection of GSD capabilities — both first-party (shipped) and third-party overlays. A row for this command also appears in [docs/COMMANDS.md](../COMMANDS.md) (that file is not edited here).
**Implemented in 1.6.0:** `install`, `update`, `remove`, `list`, `disable`, `enable` (plus the pre-existing `state` and `set` introspection/activation subcommands). **Planned (not yet implemented):** `outdated` — see [Planned subcommands](#planned-subcommands).
---
@@ -16,7 +18,7 @@ The `capability` family manages the installation, upgrade, removal, and inspecti
**Synopsis**
```
gsd capability install <spec> [--integrity sha512-<hash>] [--scope global|project] [--yes]
gsd capability install <spec> [--integrity sha512-<hash>] [--scope global|project] [--yes] [--shared-file <rel>]…
```
**Arguments**
@@ -30,16 +32,19 @@ gsd capability install <spec> [--integrity sha512-<hash>] [--scope global|projec
| Flag | Type | Default | Description |
|---|---|---|---|
| `--integrity` | `sha512-<base64>` | — | SHA-512 bundle hash to verify before extraction. When supplied, a mismatch aborts the install. When the source registry or `capability.json` already carries an `integrity` field, both must agree. |
| `--scope` | `global` \| `project` | `global` | Installation root. `global` writes to `~/.gsd/capabilities/<id>/`; `project` writes to `.gsd/capabilities/<id>/` in the current working directory. |
| `--yes` | flag | off | Suppress the interactive consent prompt. The executable-surface disclosure is still printed; consent is taken as granted. |
| `--scope` | `global` \| `project` | `global` | Installation root (see [Install layout](#install-layout)). |
| `--yes` | flag | off | Grant consent for the capability's executable surfaces non-interactively. The disclosure is still printed. Without it, an install that declares executable surfaces is **aborted** after printing the disclosure (the CLI is non-interactive — there is no prompt to answer). |
| `--shared-file` | path (repeatable) | — | A file, **relative to the scope root**, into which the capability's disclosed hooks / MCP servers should be spliced (e.g. a runtime's `settings.json`). Each fragment is marker-isolated so `remove` can strip exactly it. When omitted, the bundle still installs (declaratively); no shared-file edits are made. |
**Behaviour**
Resolves `<spec>` to a versioned, staged capability bundle. The pipeline is: fetch → verify integrity or SHA pin → check `engines.gsd` against the installed GSD version → disclose executable surfaces (hooks, command modules) → obtain consent (unless `--yes`) → validate the incoming manifest against conformance invariants over the merged first-party ∪ existing-overlay ∪ new set → extract to the scope root → write the ledger entry atomically.
Resolves `<spec>` to a versioned, staged capability bundle. The pipeline is: fetch → verify integrity or SHA pin → check `engines.gsd` against the installed GSD version → disclose executable surfaces (hooks, command modules, MCP servers) → obtain consent (a declarative capability needs none; an executable one requires `--yes`) → validate the incoming manifest against the trust invariants → extract to the scope root → write the ledger entry atomically.
An overlay whose `id` collides with a first-party capability `id`, or that claims a skill or agent stem already owned, is rejected before extraction. Install never executes capability code; staging is copy-only.
An overlay whose `id` uses a reserved first-party prefix (`gsd-`, `gsd-core-`, `anthropic-`) is rejected before extraction. Install never executes capability code; staging is copy-only. A declined install (executable surface, no `--yes`) writes **nothing** — no bundle, no ledger entry, no shared-file edits.
The ledger file (`~/.claude/.gsd-capabilities.json` for the global scope on the Claude runtime, and analogues per runtime) records the installed version, source, integrity hash, owned files, and any fragments written into shared files (e.g. hook registrations in `settings.json`).
A best-effort reconciliation sweep runs before the mutation to recover any crash orphans from a prior interrupted operation.
The ledger file (`<scope-root>/.gsd-capabilities.json`, see [Install layout](#install-layout)) records the installed version, source, integrity hash, owned files, and any fragments written into shared files.
---
@@ -48,70 +53,39 @@ The ledger file (`~/.claude/.gsd-capabilities.json` for the global scope on the
**Synopsis**
```
gsd capability update [<id> | --all]
gsd capability update [<id> | --all] [--scope global|project] [--yes] [--shared-file <rel>]…
```
**Arguments**
| Argument | Description |
|---|---|
| `<id>` | Capability identifier to update. Omitting both `<id>` and `--all` is an error. |
| `<id>` | Capability identifier to update. Omitting both `<id>` and `--all` is an error; passing both is an error. |
**Flags**
| Flag | Description |
|---|---|
| `--all` | Update every installed overlay capability that has a newer version available from its original source. |
| `--all` | Re-resolve and update **every** installed overlay capability in the chosen scope. |
| `--scope` | Scope root to operate in (`global` default; see [Install layout](#install-layout)). |
| `--yes` | Grant consent when the new version's executable set differs from the previously consented one. |
| `--shared-file` | As for `install` — where to splice the (re-derived) hook / MCP fragments. |
**Behaviour**
Fetches the latest version (or the newest version satisfying `engines.gsd`) from the capability's recorded source. Follows the atomic stage-then-swap pattern: the new bundle is fully staged, verified, and validated before the ledger write commits the swap. A crash during staging leaves the previous version intact. A crash after the ledger write leaves the new version intact; a reconciliation sweep on next run resolves any orphaned files.
Re-resolves the capability's **recorded source** (the `source` stored in its ledger entry at install time) and, if the resolved version differs, performs an atomic stage-then-swap: the new bundle is fully staged, verified, and validated before the ledger write commits the swap. A crash during staging leaves the previous version intact; a crash after the ledger write leaves the new version intact, and a reconciliation sweep on the next run resolves any orphaned files.
When `--all` is used, update availability is source-dependent:
For third-party capabilities, a version whose executable set (hooks, command modules, MCP servers) differs from the previously consented version requires `--yes` to re-consent before the swap completes; without it the update is aborted and the old version is left fully intact.
| Source kind | Update detection |
`--all` iterates every ledger entry in the scope and reports a per-capability outcome (`upgraded` / `not_installed` / `aborted` / `blocked`). Update availability is source-dependent:
| Source kind | Re-resolution behaviour |
|---|---|
| `<name>@<registry>` | Registry catalogue query |
| git (`https://…/repo.git#<tag>`) | Remote tag fetch |
| npm (`npm:@org/pkg@<range>`) | `npm dist-tags` query |
| tarball (`https://…/cap-x.y.z.tgz`) | Not auto-detectable; requires manual `install` with the new URL |
| local (`./local/path`) | Not auto-detectable |
For third-party capabilities, auto-update is **off** by default. When auto-update is enabled, a version whose executable set (hooks, command modules) differs from the previously consented version triggers a re-prompt before the swap completes.
---
### `outdated`
**Synopsis**
```
gsd capability outdated
```
**Flags**
| Flag | Description |
|---|---|
| `--json` | Emit a JSON array instead of the default table. |
**Behaviour**
Queries the source of each installed overlay capability and reports those for which a newer version is available. Capabilities installed from tarball or local-path sources are listed as `"unknown"` for latest version.
**`--json` output shape**
```json
[
{
"id": "string",
"current": "semver",
"latest": "semver | \"unknown\"",
"source": "string",
"scope": "global | project"
}
]
```
| npm (`npm:@org/pkg@<range>`) | `npm dist-tags` / range resolution |
| tarball (`https://…/cap-x.y.z.tgz`) | Re-fetch of the recorded URL |
| local (`./local/path`) | Re-read of the recorded filesystem path |
---
@@ -120,7 +94,7 @@ Queries the source of each installed overlay capability and reports those for wh
**Synopsis**
```
gsd capability remove <id> [--purge-data]
gsd capability remove <id> [--purge-data] [--scope global|project]
```
**Arguments**
@@ -133,13 +107,14 @@ gsd capability remove <id> [--purge-data]
| Flag | Description |
|---|---|
| `--purge-data` | Also remove any data files created by the capability at runtime (artefacts under the capability's declared paths that are not part of the install bundle itself). |
| `--purge-data` | Also remove data files created by the capability at runtime (artefacts under the capability's declared paths that are not part of the install bundle). |
| `--scope` | Scope root to remove from (`global` default). |
**Behaviour**
Reads the ledger entry for `<id>` and removes exactly: the owned files listed in `files`, and the fragments written into shared files listed in `sharedEdits` (e.g. hook registrations spliced into `settings.json`). Shared files are not deleted; only the capability's fragments are stripped. The ledger entry is removed atomically after all file operations complete.
Reads the ledger entry for `<id>` and removes exactly: the owned files listed in `files`, and the fragments written into shared files listed in `sharedEdits` (e.g. hook registrations spliced into a `settings.json`). Shared files themselves are not deleted; only the capability's marker-isolated fragments are stripped. The ledger entry is removed atomically after all file operations complete.
First-party capabilities (shipped with GSD) cannot be removed via this subcommand; the entire product uninstall path (`gsd --uninstall`) handles first-party removal.
First-party capabilities (shipped with GSD) **cannot** be removed via this subcommand — `remove` rejects a first-party `id` and points at the product uninstaller (`gsd --uninstall`).
---
@@ -148,18 +123,12 @@ First-party capabilities (shipped with GSD) cannot be removed via this subcomman
**Synopsis**
```
gsd capability disable <id>
gsd capability disable <id> [--config-dir <path>] [--runtime <r>] [--scope <s>]
```
**Arguments**
| Argument | Description |
|---|---|
| `<id>` | Identifier of an installed capability to disable. |
**Behaviour**
Marks the capability as disabled in the ledger. A disabled capability is present on disk but excluded from the runtime overlay; it is skipped by the registry loader and contributes no hooks, config keys, or loop extension registrations. The ledger entry is preserved; `enable` reverses the operation without re-fetching.
Marks the capability **inactive** in the runtime activation state — identical to `gsd capability set <id> --off`. A disabled capability stays on disk; it is excluded from the active surface and contributes no hooks, config keys, or loop extension registrations until re-enabled. This toggles the capability-state layer (the runtime config), not the install ledger. The id must be a capability known to the registry; activation toggling of an installed **third-party overlay** by id is not yet wired through this path — remove an overlay with `gsd capability remove`. `enable` reverses a disable without re-fetching.
---
@@ -168,18 +137,12 @@ Marks the capability as disabled in the ledger. A disabled capability is present
**Synopsis**
```
gsd capability enable <id>
gsd capability enable <id> [--config-dir <path>] [--runtime <r>] [--scope <s>]
```
**Arguments**
| Argument | Description |
|---|---|
| `<id>` | Identifier of a previously disabled capability to enable. |
**Behaviour**
Clears the disabled flag in the ledger entry for `<id>`. On the next GSD invocation, the capability is included in the runtime overlay subject to its `engines.gsd` range. If the GSD version has changed since the capability was disabled, the `engines.gsd` check is re-evaluated at load time; an incompatible capability is skipped with a warning.
Clears the inactive flag for `<id>` in the runtime activation state — identical to `gsd capability set <id> --on`. On the next GSD invocation the capability is included in the active surface again, subject to its `engines.gsd` range (an incompatible capability is still skipped with a warning at load time).
---
@@ -195,25 +158,25 @@ gsd capability list [--json]
| Flag | Description |
|---|---|
| `--json` | Emit a JSON array instead of the default table. |
| `--json` | Emit the JSON array explicitly. (In 1.6.0 `list` always emits JSON; a formatted table is planned.) |
**Behaviour**
Lists all capabilities visible to the current GSD session: first-party capabilities (shipped with GSD) and installed overlay capabilities in both global and project scopes. Disabled capabilities are included with a `disabled` status.
Lists capabilities visible to the current session: first-party capabilities (from the registry) plus installed overlay capabilities in both the `global` and `project` scopes. Emits a JSON array of descriptors.
**`--json` output shape**
**Output shape**
```json
[
{
"id": "string",
"role": "feature | runtime",
"version": "semver",
"tier": "core | standard | full",
"source": "first-party | string",
"role": "feature | runtime | null",
"version": "semver | null",
"tier": "core | standard | full | null",
"source": "first-party | <recorded source string>",
"scope": "first-party | global | project",
"status": "active | disabled | incompatible",
"title": "string"
"status": "active | incompatible",
"title": "string | null"
}
]
```
@@ -222,9 +185,20 @@ Lists all capabilities visible to the current GSD session: first-party capabilit
| Value | Meaning |
|---|---|
| `active` | Loaded and contributing to the current session. |
| `disabled` | Present in the ledger but excluded via `gsd capability disable`. |
| `incompatible` | `engines.gsd` range does not satisfy the current GSD version; skipped with a warning at load time. |
| `active` | Present and (for overlays) compatible with the running GSD version. |
| `incompatible` | An overlay whose `engines.gsd` range does not satisfy the current GSD version; skipped with a warning at load time. |
> Whether a capability has been turned off via `disable` is reported by `gsd capability state` (the activation-state view), not by `list`.
---
## Planned subcommands
These appear in ADR-1244's command surface but are **not implemented in 1.6.0**. They are documented here so the surface is explicit; invoking them returns the unknown-subcommand error listing the available set.
| Subcommand | Intended behaviour |
|---|---|
| `outdated` | Query each installed overlay's source and report those with a newer version available (`--json` for machine output). Until it ships, `update --all` re-resolves every recorded source and reports what changed. |
---
@@ -238,19 +212,21 @@ The `install` subcommand accepts the following source specification forms.
| Git URL with tag | `https://github.com/org/repo.git#v1.2.0` | Git — clones/fetches at the specified tag; `#sha:<40-hex>` pins a specific commit. |
| npm package | `npm:@org/gsd-capability-foo@^1.0.0` | npm — resolves via `npm dist-tags` / semver range; installs with `--ignore-scripts`. |
| Tarball URL | `https://host/path/cap-x.y.z.tgz` | Tarball — fetches over HTTPS, verifies SHA-512 when `--integrity` is supplied. |
| Local path | `./local/path` | Local — copies from the filesystem path relative to the current working directory. Auto-update and `outdated` detection are not available for this form. |
| Local path | `./local/path` (or an absolute path) | Local — copies from the filesystem path. Auto-update detection is not available for this form. |
All forms pass through the same pipeline: fetch → verify integrity or SHA pin → check `engines.gsd` → obtain consent → validate → extract → record ledger.
Which source forms are *permitted* is governed by the `capabilities.strict_known_registries` policy (see [Configuration](../CONFIGURATION.md) and [the capability trust model](../explanation/capability-trust-model.md)): `null`/absent is permissive, `[]` is lockdown (no third-party sources), and a host allowlist permits only matching registries. This policy is **project-scoped** — it is read from the current project's `.planning/config.json` and applied to installs run in that project regardless of `--scope`; there is no machine-wide source allowlist. (A present-but-unparseable config fails **closed** — external installs are blocked until it is fixed.)
All permitted forms pass through the same pipeline: fetch → verify integrity or SHA pin → check `engines.gsd` → obtain consent → validate → extract → record ledger.
---
## Install layout
Installed overlay capabilities are written to one of two roots, depending on `--scope`:
Installed overlay capabilities are written under a **scope root** selected by `--scope`:
| Scope | Root path | Ledger file |
|---|---|---|
| `global` | `~/.gsd/capabilities/<id>/` | Per-runtime, e.g. `~/.claude/.gsd-capabilities.json` |
| `project` | `.gsd/capabilities/<id>/` (CWD) | Per-runtime, adjacent to project root |
| Scope | Scope root | Bundle path | Ledger file |
|---|---|---|---|
| `global` | `$GSD_HOME`, defaulting to your home directory | `<root>/.gsd/capabilities/<id>/` | `<root>/.gsd-capabilities.json` |
| `project` | the current project root | `<root>/.gsd/capabilities/<id>/` | `<root>/.gsd-capabilities.json` |
The ledger is the commit point for installs and upgrades. Its entries record the installed version, original source URL, integrity hash, owned files, and shared-file edits. A reconciliation sweep on the next GSD run resolves crash orphans (files on disk without a ledger entry, or ledger entries with missing files).
The ledger is the commit point for installs and upgrades. Its entries record the installed version, original source, integrity hash, owned files, and shared-file edits. A reconciliation sweep on the next GSD run resolves crash orphans (files on disk without a ledger entry, or ledger entries with missing files). These paths are exactly the ones the runtime registry overlay reads when composing installed capabilities, so an `install` is visible to the loop without any further step.

View File

@@ -751,6 +751,14 @@ function captureStdoutSyncWrites(run) {
return captured;
}, (err) => {
restore();
// The wrapped command may have written to stdout BEFORE it threw — e.g. a --raw
// command that emits a JSON result/error envelope and THEN throws ExitError to set a
// non-zero exit code (capability set/disable on an unknown id). Without this flush that
// captured output is silently discarded (the success-path flush at the call site never
// runs on a throw). Emit it now; the error still propagates so the exit code is preserved.
if (captured) {
try { originalWriteSync.call(fs, 1, resolveAtFileOutput(captured)); } catch { /* best-effort flush */ }
}
throw err;
});
}
@@ -1415,6 +1423,86 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
// If 'loop' were ever added to SKIP_ROOT_RESOLUTION, 'capability' should
// be added at the same time to keep them consistent.
const capSubcommand = args[1];
// --- Capability management CLI helpers (ADR-1244 D5/D6; install/update/remove/list/disable/enable).
// Pure arg parsing + scope/config/host-version resolution. The lifecycle modules themselves are
// lazy-required inside each mutating branch so the common state/set paths never load them. ---
const capFlagValue = (name) => {
const i = args.indexOf(name);
if (i === -1) return undefined;
const v = args[i + 1];
if (!v || v.startsWith('--')) {
error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
return v;
};
const capHasFlag = (name) => args.includes(name);
const capRepeatedFlag = (name) => {
const out = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === name) {
const v = args[i + 1];
if (!v || v.startsWith('--')) {
error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
out.push(v);
i++; // skip the consumed value
}
}
return out;
};
// Resolve a --scope value to the lifecycle runtimeDir — the scope ROOT that holds
// .gsd/capabilities/<id> and the .gsd-capabilities.json ledger, matching capability-loader's
// read paths exactly (global → $GSD_HOME||home; project → the resolved project root). For the
// project scope this is just `cwd`: the outer dispatch already resolved cwd to the project root
// via findProjectRoot (capability is NOT in SKIP_ROOT_RESOLUTION), so no second resolve is needed.
// Note: the strict_known_registries policy (capReadStrict) is read from the PROJECT config
// regardless of --scope — it is a project-scoped policy; there is no machine-wide source allowlist.
const capResolveScope = (scope) => {
const s = scope || 'global';
if (s !== 'global' && s !== 'project') {
error(`Invalid --scope "${s}": expected global or project`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
if (s === 'project') return { scope: 'project', runtimeDir: cwd };
const os = require('node:os');
return { scope: 'global', runtimeDir: process.env.GSD_HOME || os.homedir() };
};
// capabilities.strict_known_registries policy (null=permissive, []=lockdown, [hosts]=allowlist).
// loadConfig's whitelist does not surface this key, so read config.json directly (drift-guard pattern);
// undefined => the lifecycle's permissive default. The raw value is passed THROUGH verbatim — a
// malformed (non-array, non-null) value must reach the trust gate so it can fail CLOSED, not be
// silently downgraded to permissive here.
const capReadStrict = () => {
let cfgPath;
try {
const { planningDir } = require('./lib/planning-workspace.cjs');
cfgPath = path.join(planningDir(cwd), 'config.json');
} catch {
return undefined; // cannot even resolve the project config dir — permissive default
}
if (!fs.existsSync(cfgPath)) return undefined; // no project config — permissive default
let cfg;
try {
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
} catch {
// Config is PRESENT but unreadable/unparseable: a security policy must not silently
// downgrade to permissive. Fail CLOSED — lockdown ([]) blocks external installs (local
// still allowed) until the config is fixed.
return [];
}
if (cfg && cfg.capabilities && Object.prototype.hasOwnProperty.call(cfg.capabilities, 'strict_known_registries')) {
return cfg.capabilities.strict_known_registries;
}
return undefined;
};
// Running GSD version (hard gate for engines.gsd at install/load); fail-closed to 0.0.0.
const capHostVersion = () => {
try {
const pkg = require('../../package.json'); // gsd-core/bin/ -> repo root is two up
return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
} catch {
return '0.0.0';
}
};
if (capSubcommand === 'state') {
const configDirIdx = args.indexOf('--config-dir');
let configDir = null;
@@ -1504,9 +1592,227 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
{ enabled: setEnabled, gates: Object.keys(setGates).length > 0 ? setGates : undefined, runtime: setRuntime, scope: setScope },
raw,
);
} else if (capSubcommand === 'install') {
// capability install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…
const spec = args[2];
if (!spec || spec.startsWith('--')) {
error('Missing <spec> for: capability install <spec>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
const lifecycle = require('./lib/capability-lifecycle.cjs');
const trust = require('./lib/capability-trust.cjs');
try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ }
const res = await lifecycle.installCapability(spec, {
runtimeDir,
hostVersion: capHostVersion(),
consentGranted: capHasFlag('--yes'),
integrity: capFlagValue('--integrity'),
sharedFiles: capRepeatedFlag('--shared-file'),
strictKnownRegistries: capReadStrict(),
});
if (res.status === 'installed') {
output({
status: 'installed',
id: res.id,
version: res.version,
scope,
disclosure: trust.summarizeDisclosure(res.disclosure || {}),
}, raw);
} else if (res.status === 'aborted') {
// 'aborted' always means "executable surface needs consent" in the lifecycle contract —
// match it regardless of the requiresConsent flag so a future aborted path can't fall
// through to the generic "blocked: unknown reason" arm with a misleading message.
error(
['This capability declares executable surfaces and needs your consent before install:']
.concat(trust.summarizeDisclosure(res.disclosure || {}).map((l) => ' ' + l))
.concat(['Re-run with --yes to grant consent and install.'])
.join('\n'),
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
);
} else {
error(
`capability install blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`,
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
);
}
} else if (capSubcommand === 'update') {
// capability update [<id> | --all] [--scope global|project] [--yes] [--shared-file <rel>]…
const all = capHasFlag('--all');
const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined;
if (!all && !id) {
error('capability update requires <id> or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
if (all && id) {
error('capability update: pass either <id> or --all, not both', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
const lifecycle = require('./lib/capability-lifecycle.cjs');
const ledgerMod = require('./lib/capability-ledger.cjs');
const trust = require('./lib/capability-trust.cjs');
try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ }
const ledger = ledgerMod.readLedger(runtimeDir);
const entries = (ledger && ledger.entries) || {};
const upgradeOne = async (capId) => {
const entry = entries[capId];
if (!entry) return { id: capId, status: 'not_installed' };
// expectedId pins the op to the requested id: a retargeted/edited source that now resolves
// to a different manifest id is refused by the lifecycle rather than upgrading the wrong cap.
const r = await lifecycle.upgradeCapability(entry.source, {
runtimeDir,
hostVersion: capHostVersion(),
consentGranted: capHasFlag('--yes'),
sharedFiles: capRepeatedFlag('--shared-file'),
strictKnownRegistries: capReadStrict(),
expectedId: capId,
});
return {
id: capId,
status: r.status,
fromVersion: r.fromVersion,
toVersion: r.toVersion,
requiresConsent: r.requiresConsent,
blockReasons: r.blockReasons,
disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : undefined,
};
};
if (all) {
// Sequential by design: each upgrade takes the per-scope capability lock; parallel
// runs would contend on the ledger/lock (mirrors the worktree config.lock policy).
const results = [];
for (const capId of Object.keys(entries)) {
results.push(await upgradeOne(capId));
}
const failed = results.filter((x) => x.status !== 'upgraded');
if (failed.length > 0) {
// Some entries did not upgrade (aborted / blocked) — surface as a non-zero exit so
// automation never reads a partial `--all` run as a clean success.
error(
`capability update --all: ${failed.length} of ${results.length} did not upgrade.\n` +
JSON.stringify({ scope, updated: results }, null, 2),
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
);
}
output({ scope, updated: results }, raw);
} else {
const r = await upgradeOne(id);
if (r.status === 'upgraded') {
output({ status: 'upgraded', id: r.id, fromVersion: r.fromVersion, toVersion: r.toVersion, scope, disclosure: r.disclosure }, raw);
} else if (r.status === 'not_installed') {
error(`capability "${id}" is not installed in ${scope} scope; use: capability install`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
} else if (r.status === 'aborted') {
// 'aborted' always means "needs consent" (see install) — handle it independently of the
// requiresConsent flag so it never falls through to the generic blocked arm.
error(
[`capability update for "${id}" changes its executable surface and needs your consent:`]
.concat((r.disclosure || []).map((l) => ' ' + l))
.concat(['Re-run with --yes to grant consent and update.'])
.join('\n'),
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
);
} else {
error(`capability update blocked: ${(r.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
}
} else if (capSubcommand === 'remove') {
// capability remove <id> [--purge-data] [--scope global|project]
const id = args[2];
if (!id || id.startsWith('--')) {
error('Missing <id> for: capability remove <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
const lifecycle = require('./lib/capability-lifecycle.cjs');
const ledgerMod = require('./lib/capability-ledger.cjs');
try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ }
// Ledger first: an installed overlay is removable even if its id shadows a first-party name.
// Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo).
const removeLedger = ledgerMod.readLedger(runtimeDir);
const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id));
if (!inLedger) {
const base = require('./lib/capability-loader.cjs').loadRegistry();
if (base && base.capabilities && Object.prototype.hasOwnProperty.call(base.capabilities, id)) {
error(`"${id}" is a first-party capability and cannot be removed here; use the product uninstaller (gsd --uninstall)`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
}
const res = lifecycle.removeCapability(id, { runtimeDir, removeData: capHasFlag('--purge-data') });
if (res.status === 'removed') {
output({
status: 'removed',
id,
scope,
removedFiles: res.removedFiles,
strippedEdits: res.strippedEdits,
dataPreserved: res.dataPreserved,
}, raw);
} else if (res.status === 'not_installed') {
error(`capability "${id}" is not installed in ${scope} scope`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
} else {
error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
} else if (capSubcommand === 'list') {
// capability list [--json] — emits a JSON array of capability descriptors (first-party + overlay).
const loader = require('./lib/capability-loader.cjs');
const ledgerMod = require('./lib/capability-ledger.cjs');
const semver = require('./lib/semver-compare.cjs');
const host = capHostVersion();
const rows = [];
const base = loader.loadRegistry();
const fp = (base && base.capabilities) || {};
for (const capId of Object.keys(fp)) {
const cap = fp[capId] || {};
rows.push({
id: capId,
role: cap.role || null,
version: cap.version || null,
tier: cap.tier || null,
source: 'first-party',
scope: 'first-party',
status: 'active',
title: cap.title || null,
});
}
for (const sc of ['global', 'project']) {
const { runtimeDir } = capResolveScope(sc);
const ledger = ledgerMod.readLedger(runtimeDir);
if (!ledger || !ledger.entries) continue;
for (const capId of Object.keys(ledger.entries)) {
const entry = ledger.entries[capId];
let manifest = {};
try {
manifest = JSON.parse(fs.readFileSync(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 'utf8'));
} catch { manifest = {}; }
let status = 'active';
const range = manifest.engines && manifest.engines.gsd;
if (typeof range === 'string' && range && !semver.semverSatisfies(host, range)) status = 'incompatible';
rows.push({
id: capId,
role: manifest.role || null,
version: entry.version || null,
tier: manifest.tier || null,
source: entry.source || null,
scope: sc,
status,
title: manifest.title || null,
});
}
}
output(rows, raw || capHasFlag('--json'));
} else if (capSubcommand === 'disable' || capSubcommand === 'enable') {
// capability disable|enable <id> — toggles activation state (same mechanism as: capability set <id> --off|--on).
const id = args[2];
if (!id || id.startsWith('--')) {
error(`Missing <id> for: capability ${capSubcommand} <id>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const dCfg = capFlagValue('--config-dir');
capabilityWriter.cmdCapabilitySet(
cwd,
dCfg ? path.resolve(dCfg) : null,
id,
{ enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
raw,
);
} else {
error(
`Unknown capability subcommand: ${capSubcommand}. Available: state, set`,
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, disable, enable, state, set`,
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
);
}

View File

@@ -109,6 +109,12 @@ interface LifecycleOptions {
execOverrides?: Record<string, unknown>;
/** Also delete CAPABILITY_DATA on remove (default false — data is preserved/prompted). */
removeData?: boolean;
/**
* When set, the resolved capability id MUST equal this or the operation is refused with NO writes.
* `gsd capability update <id>` passes the requested id so a source that has been retargeted or
* hand-edited to a different manifest id cannot silently act on (and overwrite) another capability.
*/
expectedId?: string;
/**
* Test seam: override the source resolver. Must honor promote:false semantics — return a
* staged dir (left on disk for the caller to promote/clean). Defaults to the real resolver.
@@ -276,6 +282,34 @@ function safeRmUnder(runtimeDir: string, rel: string): boolean {
}
}
/**
* Resolve a shared-config file path RELATIVE to runtimeDir, confined to the scope root by realpath
* (mirrors safeRmUnder). Rejects absolute paths, `..`, and any relFile whose existing parent
* directory is a symlink escaping runtimeDir — so `--shared-file evil/x.json`, where `evil` is a
* pre-planted symlink pointing outside the scope, can never write outside it. Returns the safe
* absolute path, or null when the path is unsafe.
*/
function confinedSharedFile(runtimeDir: string, relFile: unknown): string | null {
if (typeof relFile !== 'string' || !relFile || path.isAbsolute(relFile) || relFile.split(/[/\\]/).includes('..')) {
return null;
}
let realRoot: string;
try { realRoot = fs.realpathSync(runtimeDir); } catch { return null; }
const target = path.resolve(realRoot, relFile);
const parentDir = path.dirname(target);
let realParent: string;
try {
realParent = fs.realpathSync(parentDir);
} catch {
// Parent does not exist yet (created inside the scope on write): a non-existent path cannot be a
// symlink escaping the root, so a lexical containment check is sufficient.
if (parentDir !== realRoot && !parentDir.startsWith(realRoot + path.sep)) return null;
return target;
}
if (realParent !== realRoot && !realParent.startsWith(realRoot + path.sep)) return null;
return path.join(realParent, path.basename(target));
}
// ---------------------------------------------------------------------------
// Atomic directory promotion (stage -> swap, backup retained for the caller)
// ---------------------------------------------------------------------------
@@ -393,10 +427,8 @@ function applyCapabilitySharedEdits(args: {
if (hooks.length === 0 && mcpEntries.length === 0) return records;
for (const relFile of sharedFiles) {
if (typeof relFile !== 'string' || !relFile || path.isAbsolute(relFile) || relFile.split(/[/\\]/).includes('..')) {
continue;
}
const file = path.join(runtimeDir, relFile);
const file = confinedSharedFile(runtimeDir, relFile);
if (file === null) continue; // unsafe path (absolute / .. / symlink escaping the scope root)
const settings = readJsonFile(file) ?? {};
let touched = false;
@@ -427,6 +459,14 @@ function applyCapabilitySharedEdits(args: {
: {};
for (const { name, config } of mcpEntries) {
if (!name || isUnsafeKey(name)) continue;
// Marker isolation for the map-keyed mcpServers shape: only (re)write an entry we already own
// or a brand-new name. A collision with an UNOWNED entry (the user's, or another capability's)
// is SKIPPED so user config is never clobbered — hooks are arrays and append, but mcpServers is
// keyed by name, so a blind overwrite would silently destroy the existing server config.
const existing = mcpObj[name];
const ownedByUs = typeof existing === 'object' && existing !== null
&& (existing as Record<string, unknown>)[CAP_MARKER] === capId;
if (existing !== undefined && !ownedByUs) continue;
const stamped = (typeof config === 'object' && config !== null && !Array.isArray(config))
? { ...(config as Record<string, unknown>), [CAP_MARKER]: capId }
: { value: config, [CAP_MARKER]: capId };
@@ -458,8 +498,8 @@ function stripCapabilitySharedEdits(args: {
let stripped = 0;
for (const edit of sharedEdits) {
const relFile = edit && typeof edit.file === 'string' ? edit.file : '';
if (!relFile || path.isAbsolute(relFile) || relFile.split(/[/\\]/).includes('..')) continue;
const file = path.join(runtimeDir, relFile);
const file = confinedSharedFile(runtimeDir, relFile);
if (file === null) continue; // unsafe path (absolute / .. / symlink escaping the scope root)
const settings = readJsonFile(file);
if (settings === null) continue; // missing/unparseable — nothing to strip
let changed = false;
@@ -502,6 +542,22 @@ function stripCapabilitySharedEdits(args: {
return stripped;
}
/**
* Is `id` a first-party capability id (present in the committed registry)? First-party always wins,
* so an overlay reusing one of these ids — even a non-reserved name like "ui" — must be refused at
* install (the loader would skip it at load anyway; rejecting here avoids writing an inert, shadowing
* bundle). Fail-open to `false` if the registry cannot be read (the reserved-prefix gate still applies).
*/
function isFirstPartyCapabilityId(id: string): boolean {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const reg = require('./capability-registry.cjs') as { capabilities?: Record<string, unknown> };
return !!(reg && reg.capabilities && Object.prototype.hasOwnProperty.call(reg.capabilities, id));
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Install
// ---------------------------------------------------------------------------
@@ -559,6 +615,12 @@ async function installCapability(spec: string, opts: LifecycleOptions): Promise<
if (manifest === null) {
return { status: 'blocked', blockReasons: ['staged capability.json is missing or invalid'] };
}
if (opts.expectedId && resolved.id !== opts.expectedId) {
return { status: 'blocked', id: resolved.id, blockReasons: [`source resolved to capability id "${resolved.id}" but "${opts.expectedId}" was expected; refusing`] };
}
if (isFirstPartyCapabilityId(resolved.id)) {
return { status: 'blocked', id: resolved.id, blockReasons: [`"${resolved.id}" is a first-party capability id and cannot be overridden by a third-party overlay`] };
}
const verdict = trustMod.evaluateInstallTrust({
parsed: parsedPre,
@@ -697,6 +759,9 @@ async function upgradeCapability(spec: string, opts: LifecycleOptions): Promise<
if (!lock) {
return { status: 'blocked', id: resolved.id, blockReasons: ['another capability operation is in progress'] };
}
if (opts.expectedId && resolved.id !== opts.expectedId) {
return { status: 'blocked', id: resolved.id, blockReasons: [`source for "${opts.expectedId}" now resolves to a different capability id "${resolved.id}"; refusing to upgrade`] };
}
const existing = ledgerMod.readLedger(runtimeDir);
const prior = existing && Object.prototype.hasOwnProperty.call(existing.entries, resolved.id)
? existing.entries[resolved.id]

View File

@@ -24,7 +24,14 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output: coreOutput, error: coreError } = ioMod;
const { output: coreOutput } = ioMod;
// ExitError (NOT process.exit) is how every gsd-tools command signals a non-zero exit: runMain
// translates it to process.exitCode so buffered stdout flushes first. Calling process.exit() here
// truncates a just-written --raw JSON payload before the reader sees it (a real silent-output bug).
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cliExitMod = require('./cli-exit.cjs');
const { ExitError } = cliExitMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import capabilityStateMod = require('./capability-state.cjs');
@@ -444,7 +451,8 @@ function cmdCapabilitySet(
// Do NOT print human stderr lines — raw consumers parse the JSON.
coreOutput({ capabilities: result.capabilities, warnings: result.warnings, errors: result.errors }, true);
if (result.errors.length > 0) {
process.exit(1);
// Throw (don't process.exit) so the JSON written just above flushes before the process ends.
throw new ExitError(1);
}
return;
}
@@ -457,10 +465,12 @@ function cmdCapabilitySet(
process.stderr.write(`capability set: error: ${e}\n`);
}
// Exit non-zero if any errors (hard failures — requested action was not realized).
// Exit non-zero if any errors (hard failures — requested action was not realized). The per-error
// lines were already written to stderr above; signal the exit code via ExitError (not process.exit)
// so any pending stdout/stderr flushes — runMain maps it to process.exitCode.
if (result.errors.length > 0) {
coreError(`capability set: ${String(result.errors.length)} error(s) — see above`);
return; // unreachable — coreError calls process.exit(1)
process.stderr.write(`Error: capability set: ${String(result.errors.length)} error(s) — see above\n`);
throw new ExitError(1);
}
// Human-readable summary: focus on the target capability

View File

@@ -0,0 +1,475 @@
'use strict';
/**
* capability-cli.test.cjs — behavioral tests for the `gsd capability` MANAGEMENT CLI
* (ADR-1244 D5/D6): install / update / remove / list / disable / enable wired in
* gsd-tools.cjs `case 'capability'` to the Phase-4 lifecycle + Phase-3 ledger.
*
* These exercise the REAL CLI end-to-end via runGsdTools (subprocess), the REAL
* source resolver (local-path kind — no network), and a GSD_HOME-sandboxed global
* scope so no developer state is touched. They are the contract the reference doc
* (docs/reference/gsd-capability-command.md) is verified against.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// ─── Fixtures ───────────────────────────────────────────────────────────────
const tmps = [];
function tmpDir(prefix) {
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tmps.push(d);
return d;
}
test.after(() => { for (const d of tmps) cleanup(d); });
/** A GSD_HOME-sandboxed env that also neutralizes ambient GSD_ vars (test hermeticity). */
function scopeEnv(home) {
return { GSD_HOME: home, GSD_WORKSTREAM: '', GSD_PROJECT: '' };
}
/** A cwd with a .planning/ root so findProjectRoot resolves cleanly. */
function makeCwd() {
const cwd = tmpDir('cap-cli-cwd-');
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), '{}');
return cwd;
}
/** A project cwd whose config carries a given capabilities.strict_known_registries value. */
function makeCwdWithStrict(strictValue) {
const cwd = tmpDir('cap-cli-cwd-');
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(cwd, '.planning', 'config.json'),
JSON.stringify({ capabilities: { strict_known_registries: strictValue } }),
);
return cwd;
}
/**
* Write a conformant local capability source dir and return its absolute path
* (usable directly as an install <spec>). Declarative by default; pass `hooks`
* (with materialized scripts) to make it an executable surface requiring consent.
*/
function writeCapSource(id, { version = '1.0.0', hooks = [], engines, mcp } = {}) {
const src = tmpDir(`cap-cli-src-${id}-`);
const cap = {
id,
role: 'feature',
version,
title: id,
description: 'test capability',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
hooks,
config: {},
steps: [],
contributions: [],
gates: [],
};
if (engines) cap.engines = engines;
if (mcp) cap.mcpServers = mcp; // object map { name: {command, ...} } — an executable surface
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
for (const h of hooks) {
if (h && h.script) {
const p = path.join(src, h.script);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, '// artifact', 'utf8');
}
}
return src;
}
function ledgerPath(home) { return path.join(home, '.gsd-capabilities.json'); }
function capDir(home, id) { return path.join(home, '.gsd', 'capabilities', id); }
function readLedgerEntry(home, id) {
try {
const l = JSON.parse(fs.readFileSync(ledgerPath(home), 'utf8'));
return l && l.entries && l.entries[id] ? l.entries[id] : null;
} catch { return null; }
}
function parse(out) { return JSON.parse(out); }
// ─── install ────────────────────────────────────────────────────────────────
describe('capability install', () => {
test('declarative local capability installs to the global scope and records the ledger', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('declcap');
const r = runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `install failed: ${r.error || r.output}`);
const o = parse(r.output);
assert.equal(o.status, 'installed');
assert.equal(o.id, 'declcap');
assert.equal(o.scope, 'global');
assert.ok(readLedgerEntry(home, 'declcap'), 'ledger entry recorded');
assert.ok(fs.existsSync(path.join(capDir(home, 'declcap'), 'capability.json')), 'bundle extracted');
});
test('executable capability WITHOUT --yes aborts for consent and writes nothing', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('execcap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'unconsented executable install must fail');
assert.match(`${r.error}\n${r.output}`, /consent/i);
assert.equal(readLedgerEntry(home, 'execcap'), null, 'no ledger entry');
assert.ok(!fs.existsSync(capDir(home, 'execcap')), 'no install dir');
});
test('executable capability WITH --yes installs', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('execyes', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
const r = runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `install failed: ${r.error || r.output}`);
assert.equal(parse(r.output).status, 'installed');
assert.ok(readLedgerEntry(home, 'execyes'), 'ledger entry recorded');
});
test('a reserved-namespace id is blocked', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('gsd-evil');
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /blocked|reserved/i);
assert.ok(!fs.existsSync(capDir(home, 'gsd-evil')));
});
test('an engines-incompatible capability is blocked', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('engcap', { engines: { gsd: '>=99.0.0' } });
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /blocked/i);
assert.equal(readLedgerEntry(home, 'engcap'), null);
});
test('missing <spec> is a usage error', () => {
const r = runGsdTools(['capability', 'install'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Missing <spec>/i);
});
test('an invalid --scope is rejected', () => {
const src = writeCapSource('scopecap');
const r = runGsdTools(['capability', 'install', src, '--scope', 'bogus'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Invalid --scope/i);
});
});
// ─── list ─────────────────────────────────────────────────────────────────
describe('capability list', () => {
test('--json emits an array including first-party capabilities', () => {
const r = runGsdTools(['capability', 'list', '--json'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, true, `list failed: ${r.error || r.output}`);
const rows = parse(r.output);
assert.ok(Array.isArray(rows), 'list is an array');
const fp = rows.filter((x) => x.source === 'first-party');
assert.ok(fp.length > 0, 'first-party capabilities present');
assert.ok(fp.every((x) => typeof x.id === 'string' && x.scope === 'first-party'));
});
test('an installed overlay capability appears with its scope', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('listcap');
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const r = runGsdTools(['capability', 'list', '--json'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `list failed: ${r.error || r.output}`);
const row = parse(r.output).find((x) => x.id === 'listcap');
assert.ok(row, 'installed capability listed');
assert.equal(row.scope, 'global');
assert.equal(row.source, src);
assert.equal(row.version, '1.0.0');
});
});
// ─── update ─────────────────────────────────────────────────────────────────
describe('capability update', () => {
test('a not-installed id errors', () => {
const r = runGsdTools(['capability', 'update', 'nope', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /not installed/i);
});
test('requires <id> or --all', () => {
const r = runGsdTools(['capability', 'update', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /requires <id> or --all/i);
});
test('<id> and --all are mutually exclusive', () => {
const r = runGsdTools(['capability', 'update', 'foo', '--all', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /not both/i);
});
test('an installed capability upgrades to a newer version from its recorded source', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('upcap', { version: '1.0.0' });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
// Bump the recorded source to a newer version, then update by id.
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0';
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
const r = runGsdTools(['capability', 'update', 'upcap', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `update failed: ${r.error || r.output}`);
const o = parse(r.output);
assert.equal(o.status, 'upgraded');
assert.equal(o.fromVersion, '1.0.0');
assert.equal(o.toVersion, '2.0.0');
assert.equal(readLedgerEntry(home, 'upcap').version, '2.0.0');
});
});
// ─── remove ─────────────────────────────────────────────────────────────────
describe('capability remove', () => {
test('an installed overlay capability is removed (ledger + bundle gone)', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('rmcap');
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const r = runGsdTools(['capability', 'remove', 'rmcap', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `remove failed: ${r.error || r.output}`);
assert.equal(parse(r.output).status, 'removed');
assert.equal(readLedgerEntry(home, 'rmcap'), null, 'ledger entry gone');
assert.ok(!fs.existsSync(capDir(home, 'rmcap')), 'bundle gone');
});
test('a not-installed id errors', () => {
const r = runGsdTools(['capability', 'remove', 'nope', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /not installed/i);
});
test('a first-party capability cannot be removed here', () => {
// Pick a real first-party id from the registry.
const reg = require('../gsd-core/bin/lib/capability-registry.cjs');
const firstParty = Object.keys(reg.capabilities)[0];
const r = runGsdTools(['capability', 'remove', firstParty, '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /first-party/i);
});
test('missing <id> is a usage error', () => {
const r = runGsdTools(['capability', 'remove'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Missing <id>/i);
});
});
// ─── disable / enable ─────────────────────────────────────────────────────────
describe('capability disable / enable', () => {
test('disable then enable a first-party capability toggles its activation state', () => {
const cwd = makeCwd();
const rcd = tmpDir('cap-cli-rcd-');
const off = runGsdTools(['capability', 'disable', 'ui', '--config-dir', rcd, '--raw'], cwd);
assert.equal(off.success, true, `disable failed: ${off.error || off.output}`);
const ui = parse(off.output).capabilities.find((c) => c.id === 'ui');
assert.equal(ui.enabled, false, 'ui disabled');
const on = runGsdTools(['capability', 'enable', 'ui', '--config-dir', rcd, '--raw'], cwd);
assert.equal(on.success, true, `enable failed: ${on.error || on.output}`);
assert.equal(parse(on.output).capabilities.find((c) => c.id === 'ui').enabled, true, 'ui re-enabled');
});
test('disable without <id> is a usage error', () => {
const r = runGsdTools(['capability', 'disable'], makeCwd());
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Missing <id>/i);
});
});
// ─── unknown subcommand ───────────────────────────────────────────────────────
describe('capability (unknown)', () => {
test('an unknown subcommand lists the full available set', () => {
const r = runGsdTools(['capability', 'bogus'], makeCwd());
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /install, update, remove, list, disable, enable, state, set/);
});
});
// ─── review-hardening (adversarial-review fixes) ────────────────────────────
describe('capability install (trust hardening)', () => {
test('an overlay reusing a first-party capability id is blocked', () => {
// Pick a real first-party id and try to install an overlay that shadows it.
const reg = require('../gsd-core/bin/lib/capability-registry.cjs');
const firstParty = Object.keys(reg.capabilities)[0];
const home = tmpDir('cap-cli-home-');
const src = writeCapSource(firstParty);
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /first-party capability id/i);
assert.equal(readLedgerEntry(home, firstParty), null);
});
test('a malformed strict_known_registries value fails closed (does not downgrade to permissive)', () => {
// A hand-edited string instead of an array must BLOCK an external source, not be ignored.
const cwd = makeCwdWithStrict('github.com');
const r = runGsdTools(['capability', 'install', 'https://github.com/x/y.git', '--scope', 'project'], cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /must be an array|blocked/i);
});
test('strict_known_registries: [] (lockdown) blocks an external source', () => {
const cwd = makeCwdWithStrict([]);
const r = runGsdTools(['capability', 'install', 'https://github.com/x/y.git', '--scope', 'project'], cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /external capability installs are disabled|blocked/i);
});
});
describe('capability update (id-pinning + reporting)', () => {
test('update refuses when the recorded source now resolves to a different id', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('orig');
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
// Retarget the source to a different manifest id.
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.id = 'switched';
cap.version = '2.0.0';
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
const r = runGsdTools(['capability', 'update', 'orig', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /different capability id|refusing/i);
// The original is untouched; nothing named 'switched' got installed.
assert.equal(readLedgerEntry(home, 'orig').version, '1.0.0');
assert.equal(readLedgerEntry(home, 'switched'), null);
});
test('update --all exits non-zero when any entry fails to upgrade', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('exupd', { hooks: [{ event: 'PostToolUse', script: 'hooks/a.js' }] });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home)).success, true);
// Change the executable surface (new hook script) so the update needs re-consent.
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0';
cap.hooks = [{ event: 'PostToolUse', script: 'hooks/b.js' }];
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
fs.writeFileSync(path.join(src, 'hooks', 'b.js'), '// artifact');
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'partial --all failure must be non-zero');
assert.match(`${r.error}\n${r.output}`, /did not upgrade/i);
// The aborted update left the old version intact.
assert.equal(readLedgerEntry(home, 'exupd').version, '1.0.0');
});
test('a successful executable update reports the consented disclosure', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('discl', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0'; // same hook script => same exec set, no re-consent needed
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
const r = runGsdTools(['capability', 'update', 'discl', '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `update failed: ${r.error || r.output}`);
const o = parse(r.output);
assert.equal(o.status, 'upgraded');
assert.ok(Array.isArray(o.disclosure) && o.disclosure.length > 0, 'disclosure reported');
});
});
describe('capability disable (overlay boundary)', () => {
test('disabling an unknown id (non-raw) reports the error on stderr and exits non-zero', () => {
const rcd = tmpDir('cap-cli-rcd-');
const r = runGsdTools(['capability', 'disable', 'totally-unknown-xyz', '--config-dir', rcd], makeCwd());
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /unknown capability/i);
});
// Regression for the silent-stdout bug: a --raw command that writes a result/error envelope and
// then throws (to set a non-zero exit) used to lose ALL of stdout — captureStdoutSyncWrites
// buffered fd-1 output and discarded it on the throw path, and cmdCapabilitySet exited via
// process.exit() (bypassing the wrapper). On the old code stdout was 0 bytes; now the JSON
// error envelope is flushed to stdout AND the exit code stays non-zero.
test('disabling an unknown id in --raw mode emits the JSON error envelope on stdout (not silent)', () => {
const rcd = tmpDir('cap-cli-rcd-');
const r = runGsdTools(['capability', 'disable', 'totally-unknown-xyz', '--config-dir', rcd, '--raw'], makeCwd());
assert.equal(r.success, false, 'must exit non-zero');
assert.ok(r.output && r.output.length > 0, 'stdout must NOT be empty in raw error mode');
const out = JSON.parse(r.output);
assert.ok(Array.isArray(out.errors), 'JSON error envelope present on stdout');
assert.match(out.errors.join(' '), /unknown capability/i);
});
});
// ─── --shared-file safety + config fail-closed (adversarial-review R2) ──────
describe('capability install (--shared-file confinement)', () => {
test('a --shared-file whose parent is a symlink escaping the scope writes NOTHING outside it', () => {
const home = tmpDir('cap-cli-home-');
fs.mkdirSync(home, { recursive: true });
const outside = tmpDir('cap-cli-outside-');
// Plant a symlink inside the scope root pointing outside it.
fs.symlinkSync(outside, path.join(home, 'evil'));
const src = writeCapSource('symcap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
const r = runGsdTools(
['capability', 'install', src, '--scope', 'global', '--yes', '--shared-file', 'evil/settings.json', '--raw'],
makeCwd(), scopeEnv(home),
);
// Install still succeeds (the bundle installs); the unsafe shared-file edit is skipped.
assert.equal(r.success, true, `install failed: ${r.error || r.output}`);
assert.ok(!fs.existsSync(path.join(outside, 'settings.json')), 'must NOT write through the escaping symlink');
});
test('install does not clobber a user mcpServers entry whose name collides with the capability', () => {
const home = tmpDir('cap-cli-home-');
fs.mkdirSync(home, { recursive: true });
// Pre-existing user settings with an mcpServers entry the capability will also declare.
fs.writeFileSync(path.join(home, 'settings.json'), JSON.stringify({ mcpServers: { shared: { command: 'user-server' } } }));
const src = writeCapSource('mcpcap', { mcp: { shared: { command: 'cap-server' } } });
const r = runGsdTools(
['capability', 'install', src, '--scope', 'global', '--yes', '--shared-file', 'settings.json', '--raw'],
makeCwd(), scopeEnv(home),
);
assert.equal(r.success, true, `install failed: ${r.error || r.output}`);
const settings = JSON.parse(fs.readFileSync(path.join(home, 'settings.json'), 'utf8'));
assert.equal(settings.mcpServers.shared.command, 'user-server', 'user mcpServers entry must be preserved, not clobbered');
});
});
describe('capability install (config policy fail-closed)', () => {
test('an unparseable project config fails CLOSED — an external source is blocked, not silently permitted', () => {
const cwd = tmpDir('cap-cli-cwd-');
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), '{ this is not valid json');
const r = runGsdTools(
['capability', 'install', 'https://github.com/x/y.git', '--scope', 'project'],
cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' },
);
assert.equal(r.success, false, 'broken config must not silently permit an external install');
assert.match(`${r.error}\n${r.output}`, /external capability installs are disabled|blocked|array/i);
});
});
// ─── code-review coverage gaps ──────────────────────────────────────────────
describe('capability (argument + empty-state handling)', () => {
test('update --all over an empty ledger succeeds with an empty result set', () => {
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, true, `update --all failed: ${r.error || r.output}`);
const o = parse(r.output);
assert.deepEqual(o.updated, [], 'no installed capabilities → empty updated list');
});
test('a flag value that looks like another flag is rejected (no value swallowing)', () => {
const src = writeCapSource('flagcap');
// `--integrity --scope` — the value after --integrity is itself a flag, which must error, not be consumed.
const r = runGsdTools(['capability', 'install', src, '--integrity', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Missing value for --integrity/i);
});
});