* 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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user