Merge remote-tracking branch 'upstream/next' into kimi-runtime-support

# Conflicts:
#	bin/install.js
This commit is contained in:
Viktorplus
2026-06-07 19:21:53 +02:00
32 changed files with 1741 additions and 380 deletions

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 802
---
Retire the installer's one-off runtime directory helpers (`getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir`) and consolidate per-runtime global config-dir resolution onto the single canonical projection `runtime-homes:getGlobalConfigDir`, extended with the `--config-dir` override and the opencode/kilo `*_CONFIG` file-path precedence. Behavior-preserving across all 15 install runtimes. (#56)
<!-- docs-exempt: internal behavior-preserving consolidation of duplicated directory-resolution helpers; no user-facing behavior change -->

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 795
---
Make per-runtime config-mutation dispatch in the installer explicit: a new runtime config adapter registry maps each supported runtime to a typed config intent (install surface, shared-settings gate, finish-phase permission writer), and `install()`/`finishInstall()` dispatch by resolved intent instead of inline `runtime === '...'` branching. Behavior-preserving; unknown runtimes now fail loudly. (#60)
<!-- docs-exempt: internal behavior-preserving refactor of bin/install.js config dispatch into a dedicated registry module; no user-facing behavior change -->

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 797
---
**gsd-core can now be installed as a native Claude Code plugin** — a new `.claude-plugin/plugin.json` manifest enables installing gsd-core via `claude plugin install` or the zero-friction `~/.claude/skills/` auto-load path (`gsd-core@skills-dir`), with slash commands auto-namespaced as `/gsd-core:<command>` (e.g. `/gsd-core:plan-phase`) and lifecycle management via `claude plugin enable|disable|update`. gsd-core's always-on guard and update hooks are wired for the plugin path through `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`. This is additive — the existing npm / file-copy installer is unchanged.

View File

@@ -0,0 +1,16 @@
{
"name": "gsd-core",
"displayName": "GSD Core",
"version": "1.3.1-dev.0",
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
"author": {
"name": "open-gsd",
"url": "https://github.com/open-gsd"
},
"homepage": "https://github.com/open-gsd/gsd-core",
"repository": "https://github.com/open-gsd/gsd-core",
"license": "MIT",
"keywords": ["spec-driven-development", "planning", "workflow", "context-engineering", "claude-code", "gsd"],
"commands": "./commands/gsd/",
"hooks": "./hooks/hooks.json"
}

View File

@@ -30,3 +30,18 @@ jobs:
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: node scripts/lint-docs-required.cjs
- name: Detect docs/ changes
id: docs-changed
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
if git diff --name-only "origin/${BASE_REF}...HEAD" | grep -q '^docs/'; then
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
else
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Docs parity — live registry check
if: steps.docs-changed.outputs.docs_changed == 'true'
run: node --test tests/docs-parity-live-registry.test.cjs

View File

@@ -28,6 +28,7 @@ jobs:
outputs:
code_changed: ${{ steps.scope.outputs.code_changed }}
full_matrix: ${{ steps.scope.outputs.full_matrix }}
product_changed: ${{ steps.scope.outputs.product_changed }}
targeted_tests: ${{ steps.scope.outputs.targeted_tests }}
windows_tests: ${{ steps.scope.outputs.windows_tests }}
steps:
@@ -49,6 +50,7 @@ jobs:
if [ "$EVENT_NAME" != "pull_request" ]; then
{
echo "code_changed=true"
echo "product_changed=true"
echo "full_matrix=true"
echo "targeted_tests="
echo "windows_tests="
@@ -117,7 +119,7 @@ jobs:
test:
name: test (${{ matrix.os }}, ${{ matrix.node-version }})
needs: changes
if: needs.changes.outputs.code_changed == 'true'
if: needs.changes.outputs.product_changed == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 15
env:
@@ -214,6 +216,47 @@ jobs:
if: matrix.scope == 'full' && needs.changes.outputs.full_matrix == 'true'
run: npm run test:slow
test-inert:
name: test (inert CI)
needs: changes
if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.product_changed != 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: true
token: ${{ github.token }}
- name: Guard — require GitHub-hosted runner
run: node scripts/ci-guard-runner.cjs
- name: Rebase check — merge PR base branch into PR head
if: github.event_name == 'pull_request'
env:
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/ci-rebase-check.cjs
- name: Set up Node.js 22
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: 'npm'
- name: Environment check
run: npm run check:env
- name: Install dependencies
run: npm ci
- name: Dependency integrity gate
run: node scripts/check-npm-integrity.cjs
- name: Prepare scoped test list
env:
TEST_SCOPE: targeted
TARGETED_TESTS: ${{ needs.changes.outputs.targeted_tests }}
WINDOWS_TESTS: ${{ needs.changes.outputs.windows_tests }}
run: node scripts/ci-prepare-test-scope.cjs
- name: Run scoped tests
run: node scripts/run-tests.cjs --files-from .ci-selected-tests.txt
test-full:
name: full test (${{ matrix.os }}, ${{ matrix.node-version }})
needs: changes
@@ -289,7 +332,7 @@ jobs:
coverage:
needs: changes
if: needs.changes.outputs.code_changed == 'true'
if: needs.changes.outputs.product_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
@@ -336,6 +379,7 @@ jobs:
- changes
- lint-tests
- test
- test-inert
- test-full
- coverage
if: always()
@@ -345,17 +389,21 @@ jobs:
- name: Summarize required test gate
env:
CODE_CHANGED: ${{ needs.changes.outputs.code_changed }}
PRODUCT_CHANGED: ${{ needs.changes.outputs.product_changed }}
CHANGES_RESULT: ${{ needs.changes.result }}
LINT_RESULT: ${{ needs.lint-tests.result }}
TEST_RESULT: ${{ needs.test.result }}
INERT_RESULT: ${{ needs.test-inert.result }}
FULL_TEST_RESULT: ${{ needs.test-full.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
run: |
set -euo pipefail
echo "code_changed=$CODE_CHANGED"
echo "product_changed=$PRODUCT_CHANGED"
echo "changes=$CHANGES_RESULT"
echo "lint-tests=$LINT_RESULT"
echo "test=$TEST_RESULT"
echo "test-inert=$INERT_RESULT"
echo "test-full=$FULL_TEST_RESULT"
echo "coverage=$COVERAGE_RESULT"
@@ -374,19 +422,24 @@ jobs:
exit 0
fi
if [ "$TEST_RESULT" != "success" ]; then
echo "::error::test matrix did not pass"
exit 1
fi
if [ "$FULL_TEST_RESULT" != "success" ] && [ "$FULL_TEST_RESULT" != "skipped" ]; then
echo "::error::full parity matrix did not pass"
exit 1
fi
if [ "$COVERAGE_RESULT" != "success" ]; then
echo "::error::coverage did not pass"
exit 1
if [ "$PRODUCT_CHANGED" = "true" ]; then
if [ "$TEST_RESULT" != "success" ]; then
echo "::error::test matrix did not pass"
exit 1
fi
if [ "$FULL_TEST_RESULT" != "success" ] && [ "$FULL_TEST_RESULT" != "skipped" ]; then
echo "::error::full parity matrix did not pass"
exit 1
fi
if [ "$COVERAGE_RESULT" != "success" ]; then
echo "::error::coverage did not pass"
exit 1
fi
else
if [ "$INERT_RESULT" != "success" ]; then
echo "::error::inert CI lane did not pass"
exit 1
fi
fi
echo "Required test gate passed."

1
.gitignore vendored
View File

@@ -124,6 +124,7 @@ build/
/gsd-core/bin/lib/worktree-safety.cjs
/gsd-core/bin/lib/planning-workspace.cjs
/gsd-core/bin/lib/runtime-artifact-layout.cjs
/gsd-core/bin/lib/runtime-config-adapter-registry.cjs
/gsd-core/bin/lib/command-routing-hub.cjs
/gsd-core/bin/lib/core.cjs
/gsd-core/bin/lib/drift.cjs

View File

@@ -104,7 +104,7 @@ Module owning runtime identity normalization at runtime-selection seams. Canonic
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
### Installer Module
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getGlobalDir(runtime[, explicitDir])` → global path (env-var–aware per runtime); `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd/<stem>/` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd/<stem>/` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
### Package Identity Module [Planned]
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.
@@ -121,6 +121,12 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement
### Runtime Install Policy Module
Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58.
### Runtime Config Adapter Registry
Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Realizes the adapter-selection half of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60.
### Claude Code Plugin Manifest Module
Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module.
### Knowledge Graph Module
Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`.

View File

@@ -37,6 +37,7 @@ const {
applyWorktreeBaseRef,
readBaseRefFromSettings,
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
/**
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
@@ -402,224 +403,12 @@ function getConfigDirFromHome(runtime, isGlobal) {
}
/**
* Get the global config directory for OpenCode
* OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
* Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
*/
function getOpencodeGlobalDir() {
// 1. Explicit OPENCODE_CONFIG_DIR env var
if (process.env.OPENCODE_CONFIG_DIR) {
return expandTilde(process.env.OPENCODE_CONFIG_DIR);
}
// 2. OPENCODE_CONFIG env var (use its directory)
if (process.env.OPENCODE_CONFIG) {
return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
}
// 3. XDG_CONFIG_HOME/opencode
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
}
// 4. Default: ~/.config/opencode (XDG default)
return path.join(os.homedir(), '.config', 'opencode');
}
/**
* Get the global config directory for Kilo
* Kilo follows XDG Base Directory spec and uses ~/.config/kilo/
* Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo
*/
function getKiloGlobalDir() {
// 1. Explicit KILO_CONFIG_DIR env var
if (process.env.KILO_CONFIG_DIR) {
return expandTilde(process.env.KILO_CONFIG_DIR);
}
// 2. KILO_CONFIG env var (use its directory)
if (process.env.KILO_CONFIG) {
return path.dirname(expandTilde(process.env.KILO_CONFIG));
}
// 3. XDG_CONFIG_HOME/kilo
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
}
// 4. Default: ~/.config/kilo (XDG default)
return path.join(os.homedir(), '.config', 'kilo');
}
/**
* Get the global config directory for a runtime
* @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
* @param {string|null} explicitDir - Explicit directory from --config-dir flag
* Compatibility seam for tests and older installer consumers.
* Runtime home resolution now lives in runtime-homes.cjs.
*/
function getGlobalDir(runtime, explicitDir = null) {
if (runtime === 'opencode') {
// For OpenCode, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getOpencodeGlobalDir();
}
if (runtime === 'kilo') {
// For Kilo, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getKiloGlobalDir();
}
if (runtime === 'gemini') {
// Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.GEMINI_CONFIG_DIR) {
return expandTilde(process.env.GEMINI_CONFIG_DIR);
}
return path.join(os.homedir(), '.gemini');
}
if (runtime === 'codex') {
// Codex: --config-dir > CODEX_HOME > ~/.codex
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CODEX_HOME) {
return expandTilde(process.env.CODEX_HOME);
}
return path.join(os.homedir(), '.codex');
}
if (runtime === 'copilot') {
// Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.COPILOT_CONFIG_DIR) {
return expandTilde(process.env.COPILOT_CONFIG_DIR);
}
return path.join(os.homedir(), '.copilot');
}
if (runtime === 'antigravity') {
// Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > auto-detected
// ~/.gemini/{antigravity,antigravity-ide,antigravity-cli}
if (explicitDir) {
return expandTilde(explicitDir);
}
return resolveAntigravityGlobalDir();
}
if (runtime === 'cursor') {
// Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CURSOR_CONFIG_DIR) {
return expandTilde(process.env.CURSOR_CONFIG_DIR);
}
return path.join(os.homedir(), '.cursor');
}
if (runtime === 'windsurf') {
// Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.WINDSURF_CONFIG_DIR) {
return expandTilde(process.env.WINDSURF_CONFIG_DIR);
}
return path.join(os.homedir(), '.codeium', 'windsurf');
}
if (runtime === 'augment') {
// Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.AUGMENT_CONFIG_DIR) {
return expandTilde(process.env.AUGMENT_CONFIG_DIR);
}
return path.join(os.homedir(), '.augment');
}
if (runtime === 'trae') {
// Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.TRAE_CONFIG_DIR) {
return expandTilde(process.env.TRAE_CONFIG_DIR);
}
return path.join(os.homedir(), '.trae');
}
if (runtime === 'qwen') {
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.QWEN_CONFIG_DIR) {
return expandTilde(process.env.QWEN_CONFIG_DIR);
}
return path.join(os.homedir(), '.qwen');
}
if (runtime === 'hermes') {
// Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes
// Honors HERMES_HOME which Hermes users set for profile mode / Docker
// deploys (docs: https://hermes-agent.nousresearch.com/docs).
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.HERMES_HOME) {
return expandTilde(process.env.HERMES_HOME);
}
return path.join(os.homedir(), '.hermes');
}
if (runtime === 'codebuddy') {
// CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CODEBUDDY_CONFIG_DIR) {
return expandTilde(process.env.CODEBUDDY_CONFIG_DIR);
}
return path.join(os.homedir(), '.codebuddy');
}
if (runtime === 'cline') {
// Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CLINE_CONFIG_DIR) {
return expandTilde(process.env.CLINE_CONFIG_DIR);
}
return path.join(os.homedir(), '.cline');
}
if (runtime === 'kimi') {
if (explicitDir) {
return expandTilde(explicitDir);
}
return getGlobalConfigDir('kimi');
}
// Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CLAUDE_CONFIG_DIR) {
return expandTilde(process.env.CLAUDE_CONFIG_DIR);
}
return path.join(os.homedir(), '.claude');
return getGlobalConfigDir(runtime, explicitDir);
}
const banner = '\n' +
cyan + ' ██████╗ ███████╗██████╗\n' +
' ██╔════╝ ██╔════╝██╔══██╗\n' +
@@ -692,16 +481,6 @@ if (hasHelp) {
process.exit(0);
}
/**
* Expand ~ to home directory (shell doesn't expand in env vars passed to node)
*/
function expandTilde(filePath) {
if (filePath && filePath.startsWith('~/')) {
return path.join(os.homedir(), filePath.slice(2));
}
return filePath;
}
/**
* Compute the path prefix used for `@file` references in installed command/skill
* markdown. For global installs into a runtime config dir under $HOME, we
@@ -1774,11 +1553,11 @@ function getCommitAttribution(runtime) {
const resolveConfigPath = runtime === 'opencode'
? resolveOpencodeConfigPath
: resolveKiloConfigPath;
const config = readSettings(resolveConfigPath(getGlobalDir(runtime, null)));
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
result = (config && config.disable_ai_attribution === true) ? null : undefined;
} else if (runtime === 'gemini') {
// Gemini: check gemini settings.json for attribution config
const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
const settings = readSettings(path.join(getGlobalConfigDir('gemini', explicitConfigDir), 'settings.json'));
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
result = undefined;
} else if (settings.attribution.commit === '') {
@@ -1788,7 +1567,7 @@ function getCommitAttribution(runtime) {
}
} else if (runtime === 'claude') {
// Claude Code
const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
result = undefined;
} else if (settings.attribution.commit === '') {
@@ -7420,7 +7199,7 @@ function uninstall(isGlobal, runtime = 'claude') {
// Get the target directory based on runtime and install type
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
? getGlobalConfigDir(runtime, explicitConfigDir)
: path.join(process.cwd(), dirName);
const locationLabel = isGlobal
@@ -7934,7 +7713,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.opencode/
// For global installs, use ~/.config/opencode/
const opencodeConfigDir = configDir || (isGlobal
? getGlobalDir('opencode', explicitConfigDir)
? getGlobalConfigDir('opencode', explicitConfigDir)
: path.join(process.cwd(), '.opencode'));
// Ensure config directory exists
fs.mkdirSync(opencodeConfigDir, { recursive: true });
@@ -8014,7 +7793,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.kilo/
// For global installs, use ~/.config/kilo/
const kiloConfigDir = configDir || (isGlobal
? getGlobalDir('kilo', explicitConfigDir)
? getGlobalConfigDir('kilo', explicitConfigDir)
: path.join(process.cwd(), '.kilo'));
// Ensure config directory exists
fs.mkdirSync(kiloConfigDir, { recursive: true });
@@ -8668,6 +8447,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const isHermes = runtime === 'hermes';
const isCodebuddy = runtime === 'codebuddy';
const isCline = runtime === 'cline';
const configIntent = resolveRuntimeConfigIntent(runtime);
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
@@ -8722,7 +8502,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// Cline local installs write to the project root (like Claude Code) — .clinerules
// lives at the root, not inside a .cline/ subdirectory.
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
? getGlobalConfigDir(runtime, explicitConfigDir)
: isCline
? process.cwd()
: path.join(process.cwd(), dirName);
@@ -9668,7 +9448,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
throw _earlyInstallErr;
}
if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
// Capture pre-install snapshots before ANY GSD mutation
// (#2760 fix 3). On post-write schema-validation failure OR any throw
// during the mutation sequence (write failure, merge throw, etc.) we
@@ -10014,7 +9794,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCopilot) {
if (configIntent.installSurface === 'copilot-instructions') {
// Generate copilot-instructions.md
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
@@ -10028,32 +9808,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCursor) {
// Cursor uses skills — no config.toml, no settings.json hooks needed
if (configIntent.installSurface === 'profile-marker-only') {
// Cursor/Windsurf/Trae/Kimi use skills/agents — no config.toml, no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isWindsurf) {
// Windsurf uses skills — no config.toml, no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isTrae) {
// Trae uses skills — no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isKimi) {
// Kimi uses Agent Skills plus explicit custom agent YAML files. It does
// not own settings.json, hooks, rules, or update-banner/statusline config.
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCline) {
if (configIntent.installSurface === 'cline-rules') {
// Cline uses .clinerules — generate a rules file with GSD system instructions
const clinerulesDest = path.join(targetDir, '.clinerules');
const clinerules = [
@@ -10671,9 +10432,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
const isWindsurf = runtime === 'windsurf';
const isTrae = runtime === 'trae';
const isCline = runtime === 'cline';
const isKimi = runtime === 'kimi';
const configIntent = resolveRuntimeConfigIntent(runtime);
if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isKimi) {
if (shouldInstallStatusline && configIntent.writesSharedSettings && !isOpencode) {
if (!isGlobal && !forceStatusline) {
// Local installs skip statusLine by default: repo settings.json takes precedence over
// profile-level settings.json in Claude Code, so writing here would silently clobber
@@ -10699,7 +10460,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
// settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
// cline either lack the surface or use a different config schema.
const { shouldInstallBanner, bannerCommand } = bannerOpts;
if (shouldInstallBanner && settings && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) {
if (shouldInstallBanner && settings && configIntent.writesSharedSettings && !isOpencode) {
if (!bannerCommand) {
console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
} else {
@@ -10731,17 +10492,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
// {type: 'command', command: null} items that the runtime hook schema
// rejects at parse time. validateHookFields filters those out so the file
// we write is always schema-valid.
if (settingsPath && settings && !isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi) {
if (settingsPath && settings && configIntent.writesSharedSettings) {
writeSettings(settingsPath, validateHookFields(settings));
}
// Configure OpenCode permissions
if (isOpencode && !process.env.GSD_TEST_MODE) {
if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
configureOpencodePermissions(isGlobal, configDir);
}
// Configure Kilo permissions
if (isKilo) {
if (configIntent.finishPermissionWriter === 'kilo') {
configureKiloPermissions(isGlobal, configDir);
}
@@ -11099,7 +10860,7 @@ function promptLocation(runtimes) {
});
const pathExamples = runtimes.map(r => {
const globalPath = getGlobalDir(r, explicitConfigDir);
const globalPath = getGlobalConfigDir(r, explicitConfigDir);
return globalPath.replace(os.homedir(), '~');
}).join(', ');

View File

@@ -1020,6 +1020,8 @@ fix(03-01): correct auth token expiry
| Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
| Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | `.clinerules` | Config | Config | Config |
**Claude Code native plugin distribution:** GSD Core ships a `.claude-plugin/plugin.json` manifest, enabling installation and lifecycle management via `claude plugin install|enable|disable|update gsd-core`. Commands load under the `/gsd-core:` namespace (e.g. `/gsd-core:plan-phase`), avoiding slash-command collisions with the classic npm installer which uses `/gsd:`. Always-on guard and update hooks are wired automatically via `hooks/hooks.json`. The plugin path is additive — the npm installer (`npx @opengsd/gsd-core`) remains fully supported.
---
### 37. Hook System

View File

@@ -325,6 +325,7 @@
"roadmap-upgrade.cjs",
"roadmap.cjs",
"runtime-artifact-layout.cjs",
"runtime-config-adapter-registry.cjs",
"runtime-homes.cjs",
"runtime-name-policy.cjs",
"runtime-slash.cjs",

View File

@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (89 shipped)
## CLI Modules (90 shipped)
Full listing: `gsd-core/bin/lib/*.cjs`.
@@ -436,6 +436,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback |
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
| `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) |
| `runtime-config-adapter-registry.cjs` | Explicit runtime config adapter registry — resolves per-runtime config-mutation install intent (install surface, shared-settings gate, finish-phase permission writer); see ADR-58. |
| `runtime-name-policy.cjs` | Runtime name normalization policy — canonical token sanitization for runtime identifiers used in path construction and display |
| `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) |
| `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (codex) in user-facing output and persisted artifacts (#3584) |

View File

@@ -0,0 +1,70 @@
# Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract
- **Status:** Accepted
- **Date:** 2026-06-07
- **Issue:** #766
- **Implementation:** PR #797
## Context
gsd-core has, until now, reached Claude Code through exactly one Adapter: the file-copy installer. The **Runtime Artifact Layout Module** (ADR-3660) projects gsd-core's artifact surfaces (`commands`, `agents`, `skills`) onto per-runtime filesystem placements, and the **Runtime Install Policy Module** (ADR-58) composes those placements with command text and config intentions into a typed install plan that adapters write to `~/.claude/` / `.claude/`.
Claude Code now exposes a second, first-class way to receive the same surfaces: the **plugin contract** — a `.claude-plugin/plugin.json` manifest plus a `hooks/hooks.json`, consumed either by a marketplace install or by the zero-friction `@skills-dir` path. This contract is an *external interface owned by Claude Code*, not by gsd-core: it has its own schema, its own namespacing rules (`/<plugin-name>:<command>`), its own validation tool (`claude plugin validate`), and its own constraints (notably: plugin-shipped agents may not carry `hooks` / `permissionMode` / `mcpServers` frontmatter — Claude Code silently ignores them).
Before this ADR, the only record of how gsd-core maps onto that external contract was the manifest files themselves. A hand-authored config file with no named Seam invites drift: the manifest's hook wiring silently diverges from what the Installer Module wires into `settings.json`; the identity fields drift from the Package Identity Module; and a future maintainer has no single place that says *which gsd-core surface maps to which manifest field, and why*. The plugin contract is exactly the kind of external interface that earns a defined, typed mapping rather than an ad-hoc file — the same reasoning that gave the file-copy path the Runtime Artifact Layout Module.
This is the structural signal the architecture review looks for: **two Adapters at one Seam.** The file-copy layout and the plugin manifest are two projections of *the same* gsd-core artifact surfaces onto two different distribution contracts. That makes the distribution Seam real, and the plugin-side projection deserves a name.
## Decision
Introduce the **Claude Code Plugin Manifest Module** as the Seam that owns the projection of gsd-core's artifact surfaces onto the Claude Code plugin contract. It is the plugin-contract sibling of the Runtime Artifact Layout Module: where that Module projects surfaces onto filesystem placements, this Module projects the same surfaces onto `.claude-plugin/plugin.json` + `hooks/hooks.json`.
The mapping is **defined, not incidental**:
| gsd-core surface / source | Claude Code plugin field | Rule / invariant |
|---|---|---|
| Package Identity Module `binName` | `name` | `gsd-core` — drives the `/gsd-core:` command namespace; must be kebab-case (no colon/space/uppercase). |
| Package Identity Module `repoUrl` | `repository`, `homepage` | derived, never re-typed. |
| `package.json` `version` / `description` / `license` | `version` / `description` / `license` | `version` is **required** for `claude plugin validate --strict` (a missing version is a strict failure), so it is synced to `package.json` and held by a drift-guard test. |
| Command surface (`commands/gsd/*.md`) | `commands: "./commands/gsd/"` | exposed as `/gsd-core:<command>`; namespacing replaces the file-copy path's `/gsd:<command>` (an additive UX change, not a data-format break). |
| Agent surface (`agents/*.md`) | *(omitted — default `agents/` discovery)* | the explicit `agents: <string>` form is rejected by the plugin schema; relying on Claude Code's default `agents/` discovery loads them and stays self-maintaining. Agents are already plugin-safe — their `hooks`/`permissionMode` frontmatter is inert. |
| Always-on hook policy (subset of the Installer Module's `settings.json` wiring) | `hooks: "./hooks/hooks.json"` | see below. |
The hook projection is the load-bearing part of this Module, because of the external constraint: a plugin's agents cannot carry hook frontmatter, so **all plugin-path hook wiring must live in `hooks/hooks.json`**. The Module projects *only the always-on subset* of the Installer Module's Claude hook wiring — `gsd-check-update` (SessionStart), `gsd-context-monitor` (PostToolUse), and the security guards `gsd-prompt-guard` / `gsd-read-guard` / `gsd-worktree-path-guard` / `gsd-read-injection-scanner` — preserving each event, matcher, and timeout. The installer's **config-gated opt-in** hooks (workflow-guard, validate-commit, graphify-update, session-state, phase-boundary, update-banner) are deliberately excluded: a static manifest cannot read a project's `.planning/config.json` to honor those gates, so projecting them would run them unconditionally — a behavior change the Module must not introduce. Hook commands reference bundled scripts through Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable.
The interface of this Module is therefore a **conformance contract**, validated two ways: `claude plugin validate --strict` (the external tool's view) and an in-repo drift-guard test (`tests/issue-766-plugin-manifest.test.cjs`) that locks the identity mapping, the version sync, the always-on hook contract, and the absence of opt-in hooks. Manifest component paths are resolved relative to the **plugin root** (the directory containing `.claude-plugin/`), which is the repository root.
This is **additive**. The file-copy path — Runtime Artifact Layout Module, Runtime Install Policy Module, Installer Module — is unchanged. The plugin manifest is a parallel Adapter, the fallback for users on older Claude Code versions that predate the plugin contract.
## What stays OUTSIDE this Module
To keep the Seam honest about where the plugin contract ends:
- **Runtime execution.** The Module projects the command/agent/hook *surface* and lifecycle metadata. It does not make gsd commands self-contained: their backing logic still resolves the gsd runtime CLI (`gsd-tools`) and `node` on `PATH`. The plugin delivers discoverability and lifecycle (`claude plugin enable|disable|update`); it does not replace the runtime.
- **The file-copy install.** Filesystem placement, `settings.json` merge semantics, and per-runtime config rendering remain owned by the Runtime Artifact Layout / Install Policy / Installer Modules.
- **Marketplace listing.** Publishing gsd-core to a marketplace registry is an external, out-of-repo act.
- **Manifest emission by the installer.** Having `bin/install.js` drop the manifest in-place for the npm `@skills-dir` path is a follow-up; the repo-root manifest already serves the marketplace and git-clone `@skills-dir` paths.
## Consequences
- gsd-core gains a one-command install/update/disable lifecycle and automatic `/gsd-core:` namespacing that prevents slash-command collisions, without disturbing the file-copy path.
- The plugin contract gains a named place in the glossary (`CONTEXT.md`) and a defined mapping, so future surface additions have an obvious projection target instead of an ad-hoc file edit.
- **Latent duplication is now named, not hidden.** The always-on hook policy is currently encoded twice — imperatively in the Installer Module's `settings.json` wiring, and declaratively in `hooks/hooks.json` — kept in agreement only by the drift-guard test. This ADR records that as the known cost of a *static* manifest. Elevating the Module from a hand-authored manifest to a **generated projection** (stamping `plugin.json` from the Package Identity Module + `package.json`, and `hooks/hooks.json` from `managed-hooks-registry.cjs` + a shared always-on-hook policy) would collapse the duplication to one source — the same generated-single-source move ADR-457 made for `.cjs` and the Runtime Install Policy Module made for install plans. Deferred; see Open questions.
- The `name` field is a stability surface: it is the published `/gsd-core:` namespace. Changing it is a user-visible break under Hyrum's law, the same way command names are.
- Rollout is incremental: this ADR + the hand-authored manifest land first (#766/PR#797); installer-emit, release-time version stamping, and the generated projection are tracked follow-ups under #766.
## Open questions
- Should this Module be **generated** rather than hand-authored, deriving `version` (and identity) at build/release time so a `package.json` bump cannot leave `plugin.json` stale? The release pipeline bumps via `npm version --no-git-tag-version` with no regeneration hook, so today the drift-guard test enforces the sync manually (idiomatic with the repo's other drift guards, but a release speed-bump).
- Should the always-on-hook policy be lifted into a single shared source consumed by *both* the Installer Module and this Module, retiring the dual hand-encoding?
## References
- ADR-3660 — Runtime Artifact Layout Module (the file-copy sibling: projects the same surfaces onto filesystem placements).
- ADR-58 — Runtime Install Policy Module (typed install-plan projection for the file-copy path).
- ADR-457 — Generated single-source (the precedent a generated manifest projection would follow).
- ADR-0008 — Installer Migration Module (adjacent installer Seam).
- Package Identity Module (`gsd-core/bin/lib/package-identity.cjs`) — source of the manifest's identity fields.
- Installer Module (`bin/install.js`) — owns the `settings.json` always-on hook wiring this Module mirrors for the plugin path.
- `CONTEXT.md` § Glossary — Domain modules and seams (where this Module is registered).
- Claude Code plugin contract: <https://code.claude.com/docs/en/plugins-reference>.

View File

@@ -55,6 +55,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop
| [457-generated-cjs-single-source.md](457-generated-cjs-single-source.md) | Collapse hand-written CJS to generated single-source | Proposed |
| [660-release-from-next-head.md](660-release-from-next-head.md) | Release from the head of next; immutable release tags; @next dist-tag as the RC surface | Proposed |
| [58-runtime-install-policy-module.md](58-runtime-install-policy-module.md) | Runtime Install Policy Module owns the typed install-plan projection | Accepted |
| [766-claude-code-plugin-manifest-module.md](766-claude-code-plugin-manifest-module.md) | Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract | Accepted |
## Seam map

View File

@@ -44,6 +44,50 @@ CLAUDE_CONFIG_DIR=~/.claude-alt npx @opengsd/gsd-core@latest --claude --global
---
### Claude Code — native plugin install
GSD Core ships a `.claude-plugin/plugin.json` manifest, which enables installation and lifecycle management through the Claude Code plugin system. This path is **additive** — the npm installer above remains fully supported, and the two approaches differ in namespace and lifecycle only.
**Install paths**
*Option A — marketplace or git install (once listed):*
```bash
claude plugin install gsd-core
```
*Option B — zero-friction skills-dir load:* Claude Code automatically discovers any directory under `~/.claude/skills/` that contains a `.claude-plugin/plugin.json` as a plugin. To use gsd-core this way, place (or symlink) the gsd-core package directory there:
```bash
# Example: place the package under ~/.claude/skills/gsd-core/
# Claude Code loads it as gsd-core@skills-dir on the next session start.
# No explicit install step required.
```
**Command namespace**
Plugin commands are namespaced as `/gsd-core:<command>` — for example, `/gsd-core:plan-phase`. This is distinct from the classic npm/file-copy installer, which exposes commands as `/gsd:<command>`. Use whichever namespace corresponds to your install method.
**Lifecycle**
```bash
claude plugin enable gsd-core
claude plugin disable gsd-core
claude plugin update gsd-core
```
**Hooks**
The plugin wires gsd-core's always-on guard and update hooks automatically via `hooks/hooks.json`. No manual hook registration is required.
**Prerequisites**
The `gsd-tools` binary (installed as part of the `@opengsd/gsd-core` npm package) must be available on your `PATH` for gsd commands to execute their backing logic. The plugin delivers the command, agent, and hook surface; the npm package delivers the runtime CLI.
Node.js (`node`) must also be available on your `PATH`. The plugin's always-on guard hooks (wired in `hooks/hooks.json`) are invoked as `node "${CLAUDE_PLUGIN_ROOT}/hooks/<script>"`. Some Claude Code distributions ship as a standalone binary and do not expose a `node` executable on `PATH`; in those environments the plugin's hooks will not run. Verify with `node --version` before relying on the plugin hooks.
---
### Gemini CLI
```bash

View File

@@ -86,6 +86,7 @@ export default tseslint.config(
'gsd-core/bin/lib/worktree-base-ref.cjs',
'gsd-core/bin/lib/planning-workspace.cjs',
'gsd-core/bin/lib/runtime-artifact-layout.cjs',
'gsd-core/bin/lib/runtime-config-adapter-registry.cjs',
'gsd-core/bin/lib/command-routing-hub.cjs',
'gsd-core/bin/lib/core.cjs',
'gsd-core/bin/lib/drift.cjs',

40
hooks/hooks.json Normal file
View File

@@ -0,0 +1,40 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js\"" }
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-prompt-guard.js\"", "timeout": 5 },
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-guard.js\"", "timeout": 5 }
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-worktree-path-guard.js\"", "timeout": 5 }
]
}
],
"PostToolUse": [
{
"matcher": "Bash|Edit|Write|MultiEdit|Agent|Task",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 }
]
},
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-injection-scanner.js\"", "timeout": 5 }
]
}
]
}
}

View File

@@ -12,6 +12,7 @@
"gsd-core",
"assets",
"agents",
".claude-plugin",
"hooks",
"scripts"
],

View File

@@ -6,16 +6,84 @@ const { existsSync, readdirSync, appendFileSync } = require('fs');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// Workflow files that are purely administrative / policy bots. Changes to these
// files do NOT require the cross-platform test matrix — only a lightweight
// ubuntu lane running workflow-lint tests is needed.
// FAIL-SAFE: any .github/workflows/*.yml NOT listed here is treated as a
// pipeline workflow and gets the full matrix. New workflow files default to full.
const INERT_WORKFLOWS = new Set([
'stale.yml',
'branch-cleanup.yml',
'branch-naming.yml',
'auto-label-issues.yml',
'auto-branch.yml',
'auto-backmerge.yml',
'close-draft-prs.yml',
'dismiss-unauthorized-pr-approvals.yml',
'pr-gate.yml',
'pr-target-validator.yml',
'pr-template-format.yml',
'require-issue-link.yml',
'changeset-required.yml',
'docs-required.yml',
'discord-changelog.yml',
]);
// Workflows that gate merges, ship the product, or run security/cross-platform
// suites — these must ALWAYS get the full pipeline treatment and can never be
// added to INERT_WORKFLOWS. A module-load assertion enforces this so a mistaken
// or malicious addition fails CI loudly in the `changes` job on every PR.
const PROTECTED_WORKFLOWS = new Set([
'test.yml',
'install-smoke.yml',
'mutation.yml',
'security-scan.yml',
'release.yml',
]);
for (const wf of PROTECTED_WORKFLOWS) {
if (INERT_WORKFLOWS.has(wf)) {
throw new Error(`ci-test-scope: protected workflow "${wf}" must not be in INERT_WORKFLOWS (it requires the full test matrix).`);
}
}
/**
* Returns true if the path is an inert (non-pipeline) workflow file.
* Only `.github/workflows/<name>` where <name> is in INERT_WORKFLOWS qualifies.
*/
function isInertCi(filePath) {
if (!filePath.startsWith('.github/workflows/')) return false;
const name = filePath.slice('.github/workflows/'.length);
// Must be a direct child (no further slashes) and in the allowlist.
return !name.includes('/') && INERT_WORKFLOWS.has(name);
}
// Tests shared by both the 'workflow automation' and 'inert CI' rules.
const WORKFLOW_LINT_TESTS = [
'tests/workflow-shell-pinning.test.cjs',
'tests/pr-template-policy.test.cjs',
'tests/lint-pr-check-project-dir.test.cjs',
];
const RULES = [
{
name: 'workflow automation',
match: path => path.startsWith('.github/workflows/') || path.startsWith('.github/rulesets/'),
// Only NON-inert .github/workflows/* and all .github/rulesets/* trigger full matrix.
// FAIL-SAFE: any .github/workflows/*.yml not in INERT_WORKFLOWS is treated as pipeline.
match: filePath => (filePath.startsWith('.github/workflows/') && !isInertCi(filePath)) ||
filePath.startsWith('.github/rulesets/'),
fullMatrix: true,
tests: [
'tests/workflow-shell-pinning.test.cjs',
...WORKFLOW_LINT_TESTS,
'tests/release-tarball-smoke-workflow.test.cjs',
'tests/lint-pr-check-project-dir.test.cjs',
'tests/pr-template-policy.test.cjs',
],
},
{
name: 'inert CI',
match: filePath => isInertCi(filePath),
fullMatrix: false,
tests: [
...WORKFLOW_LINT_TESTS,
'tests/policy-lint-shallow-checkout.test.cjs',
],
},
{
@@ -144,14 +212,6 @@ const RULES = [
'tests/docs-parity-live-registry.test.cjs',
],
},
{
name: 'docs content',
match: path => path.startsWith('docs/'),
fullMatrix: false,
tests: [
'tests/docs-parity-live-registry.test.cjs',
],
},
{
name: 'configuration',
match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)),
@@ -251,16 +311,30 @@ function classify(files) {
const targeted = new Set();
const windows = new Set();
const reasons = [];
let codeChanged = false;
let productOrPipelineChanged = false; // product/pipeline code (excludes docs)
let inertCiChanged = false; // inert workflow files
let fullMatrix = false;
for (const file of files) {
if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) ||
// Determine if this file is product/pipeline code.
// docs/ and root-level .md files are intentionally excluded.
if (
['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) ||
file === 'package.json' || file === 'package-lock.json' ||
(file.startsWith('tsconfig') && file.endsWith('.json')) ||
file.startsWith('.github/workflows/') ||
file.startsWith('.github/rulesets/')) {
codeChanged = true;
file.startsWith('.github/rulesets/')
) {
productOrPipelineChanged = true;
}
// Non-inert .github/workflows/* are pipeline code → full matrix.
if (file.startsWith('.github/workflows/') && !isInertCi(file)) {
productOrPipelineChanged = true;
}
// Inert workflow files set a lightweight signal.
if (isInertCi(file)) {
inertCiChanged = true;
}
if (file.startsWith('tests/') && file.endsWith('.test.cjs')) {
@@ -280,6 +354,10 @@ function classify(files) {
}
}
// code_changed: true when product/pipeline OR inert CI changed.
// Docs-only PRs (neither flag set) get code_changed=false → full matrix skip.
const codeChanged = productOrPipelineChanged || inertCiChanged;
const targetedTests = existingTests([...targeted].sort());
// When code changed but no rule matched any changed file, fall back to the
@@ -290,8 +368,25 @@ function classify(files) {
const windowsTests = existingTests([...new Set([...windows, ...targetedTests.filter(isWindowsHint)])].sort());
// Inert-CI-only: full_matrix must be false (override any RULES that fired).
if (inertCiChanged && !productOrPipelineChanged) {
fullMatrix = false;
}
// Normalize: when code_changed is false, the output must be self-consistent.
// A docs file can coincidentally match a coarse content RULE (e.g. docs/installer-migrations.md
// matches the installer rule via path.includes('install')), leaving full_matrix=true and
// non-empty targeted_tests/windows_tests. The workflow skips correctly (gated on code_changed)
// but the output object would be self-contradictory. Force a clean "nothing to run" result.
if (!codeChanged) {
fullMatrix = false;
targetedTests.length = 0;
windowsTests.length = 0;
}
return {
code_changed: codeChanged,
product_changed: productOrPipelineChanged,
full_matrix: fullMatrix,
targeted_tests: targetedTests,
windows_tests: windowsTests,
@@ -303,6 +398,7 @@ function writeOutputs(result) {
if (!process.env.GITHUB_OUTPUT) return;
const lines = [
`code_changed=${result.code_changed}`,
`product_changed=${result.product_changed}`,
`full_matrix=${result.full_matrix}`,
`targeted_tests=${result.targeted_tests.join(' ')}`,
`windows_tests=${result.windows_tests.join(' ')}`,
@@ -313,6 +409,7 @@ function writeOutputs(result) {
function main() {
try {
const args = parseArgs(process.argv.slice(2));
const files = changedFiles(args);
const result = classify(files);
result.changed_files = files;

View File

@@ -445,7 +445,7 @@ function cmdEffortSync(cwd: string, raw: boolean, opts?: { dryRun?: boolean; con
}
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
const { getGlobalConfigDir } = require('./runtime-homes.cjs') as { getGlobalConfigDir(runtime: string): string };
const { getGlobalConfigDir } = require('./runtime-homes.cjs') as { getGlobalConfigDir(runtime: string, explicitDir?: string | null): string };
// Use install-time resolvers: they merge ~/.gsd/defaults.json with project config,
// matching the exact logic used when agents were originally installed.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method

View File

@@ -0,0 +1,110 @@
'use strict';
/**
* Runtime config adapter registry — explicit dispatch table for install-phase
* config mutations (issue #60), replacing inline `runtime === '...'` branching
* in bin/install.js.
*
* Design notes:
* - `installSurface` selects which config handler install() runs:
* 'settings-json' → fall through to the shared settings.json accumulation.
* 'codex-toml' → early-return after writing codex.toml.
* 'copilot-instructions' → early-return after writing .github/copilot-instructions.md.
* 'cline-rules' → early-return after writing .clinerules.
* 'profile-marker-only' → early-return after writing only the profile marker.
* - `writesSharedSettings` is the finishInstall writeSettings gate:
* false for codex / copilot / kilo / cursor / windsurf / trae / cline / kimi (legacy exclusion list).
* true for all other runtimes.
* - `finishPermissionWriter` names the finishInstall-phase dedicated config writer:
* 'opencode' → writes BOTH shared settings AND its own permissions file.
* 'kilo' → writes only its own permissions file.
* null → no dedicated permission writer.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ConfigInstallSurface =
| 'settings-json'
| 'codex-toml'
| 'copilot-instructions'
| 'cline-rules'
| 'profile-marker-only';
type FinishPermissionWriter = 'opencode' | 'kilo' | null;
interface RuntimeConfigIntent {
runtime: string;
installSurface: ConfigInstallSurface;
writesSharedSettings: boolean;
finishPermissionWriter: FinishPermissionWriter;
}
interface RegistryEntry {
installSurface: ConfigInstallSurface;
writesSharedSettings: boolean;
finishPermissionWriter: FinishPermissionWriter;
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
const REGISTRY: Record<string, Readonly<RegistryEntry>> = Object.freeze({
claude: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const),
gemini: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const),
antigravity: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const),
augment: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const),
qwen: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const),
hermes: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const),
codebuddy: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const),
opencode: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' } as const),
kilo: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' } as const),
codex: Object.freeze({ installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null } as const),
copilot: Object.freeze({ installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null } as const),
cline: Object.freeze({ installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null } as const),
cursor: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const),
windsurf: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const),
trae: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const),
kimi: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const),
});
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
/** The complete set of 16 supported runtimes for config-adapter dispatch. */
const ALLOWED_CONFIG_RUNTIMES: ReadonlySet<string> = new Set(Object.keys(REGISTRY));
/** All valid installSurface values. */
const INSTALL_SURFACES: ReadonlyArray<ConfigInstallSurface> = Object.freeze([
'settings-json',
'codex-toml',
'copilot-instructions',
'cline-rules',
'profile-marker-only',
]);
/**
* Resolve the config adapter intent for a given runtime.
*
* Returns a fresh object each call so callers cannot poison the registry by
* mutating the returned value.
*
* @throws {TypeError} if runtime is not a known supported runtime.
*/
function resolveRuntimeConfigIntent(runtime: string): RuntimeConfigIntent {
if (!Object.hasOwn(REGISTRY, runtime)) {
throw new TypeError(`Unknown runtime for config adapter: ${runtime}`);
}
const entry = REGISTRY[runtime];
return {
runtime,
installSurface: entry.installSurface,
writesSharedSettings: entry.writesSharedSettings,
finishPermissionWriter: entry.finishPermissionWriter,
};
}
export = { resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES };

View File

@@ -100,8 +100,15 @@ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string {
/**
* Return the global config base directory for the given runtime.
* Respects the same env-var overrides as bin/install.js getGlobalDir().
*
* @param runtime - The runtime identifier (e.g. 'claude', 'opencode').
* @param explicitDir - If provided and non-empty, returned immediately after
* tilde-expansion, overriding all env-var and default logic. This matches
* the behaviour of bin/install.js getGlobalDir(runtime, explicitDir).
*/
export function getGlobalConfigDir(runtime: string): string {
export function getGlobalConfigDir(runtime: string, explicitDir?: string | null): string {
if (explicitDir) return expandTilde(explicitDir);
const home = os.homedir();
const env = process.env as Record<string, string | undefined>;
@@ -172,6 +179,7 @@ export function getGlobalConfigDir(runtime: string): string {
// ── OpenCode (XDG) ───────────────────────────────────────────────────────
case 'opencode': {
if (env['OPENCODE_CONFIG_DIR']) return expandTilde(env['OPENCODE_CONFIG_DIR']);
if (env['OPENCODE_CONFIG']) return path.dirname(expandTilde(env['OPENCODE_CONFIG']));
if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'opencode');
return path.join(home, '.config', 'opencode');
}
@@ -179,6 +187,7 @@ export function getGlobalConfigDir(runtime: string): string {
// ── Kilo (XDG) ───────────────────────────────────────────────────────────
case 'kilo': {
if (env['KILO_CONFIG_DIR']) return expandTilde(env['KILO_CONFIG_DIR']);
if (env['KILO_CONFIG']) return path.dirname(expandTilde(env['KILO_CONFIG']));
if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'kilo');
return path.join(home, '.config', 'kilo');
}

View File

@@ -65,7 +65,8 @@ describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => {
const envKeys = ['CLAUDE_CONFIG_DIR','CURSOR_CONFIG_DIR','GEMINI_CONFIG_DIR',
'CODEX_HOME','COPILOT_CONFIG_DIR','ANTIGRAVITY_CONFIG_DIR','WINDSURF_CONFIG_DIR',
'AUGMENT_CONFIG_DIR','TRAE_CONFIG_DIR','QWEN_CONFIG_DIR','HERMES_HOME',
'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','KILO_CONFIG_DIR',
'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','OPENCODE_CONFIG',
'KILO_CONFIG_DIR','KILO_CONFIG',
'XDG_CONFIG_HOME'];
const saved = {};
for (const k of envKeys) { saved[k] = process.env[k]; delete process.env[k]; }
@@ -105,15 +106,19 @@ describe('bug #3126: runtime-homes env-var overrides', () => {
});
test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => {
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
withEnv('OPENCODE_CONFIG', undefined, () => {
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
});
});
});
});
test('kilo uses XDG_CONFIG_HOME when KILO_CONFIG_DIR absent', () => {
withEnv('KILO_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo'));
withEnv('KILO_CONFIG', undefined, () => {
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo'));
});
});
});
});
@@ -186,6 +191,110 @@ describe('bug #3126: runtime-homes getGlobalSkillDir', () => {
});
});
describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-path precedence', () => {
// ── explicitDir override ──────────────────────────────────────────────────
test('explicitDir absolute path is returned as-is (claude)', () => {
assert.strictEqual(getGlobalConfigDir('claude', '/tmp/x'), '/tmp/x');
});
test('explicitDir with tilde is expanded (opencode)', () => {
assert.strictEqual(
getGlobalConfigDir('opencode', '~/foo'),
path.join(os.homedir(), 'foo'),
);
});
test('explicitDir wins even when OPENCODE_CONFIG_DIR is also set', () => {
withEnv('OPENCODE_CONFIG_DIR', '/should/not/win', () => {
assert.strictEqual(getGlobalConfigDir('opencode', '/explicit/wins'), '/explicit/wins');
});
});
// ── opencode: OPENCODE_CONFIG file-path step ──────────────────────────────
test('opencode: OPENCODE_CONFIG → path.dirname(expandTilde(value))', () => {
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', undefined, () => {
withEnv('OPENCODE_CONFIG', '/home/u/cfg/opencode.json', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/home/u/cfg');
});
});
});
});
test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => {
withEnv('OPENCODE_CONFIG_DIR', '/dir/wins', () => {
withEnv('OPENCODE_CONFIG', '/file/loses.json', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/dir/wins');
});
});
});
test('opencode: OPENCODE_CONFIG takes precedence over XDG_CONFIG_HOME', () => {
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
withEnv('OPENCODE_CONFIG', '/cfg/opencode.json', () => {
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/cfg');
});
});
});
});
test('opencode: default ~/.config/opencode when no env vars set', () => {
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
withEnv('OPENCODE_CONFIG', undefined, () => {
withEnv('XDG_CONFIG_HOME', undefined, () => {
assert.strictEqual(
getGlobalConfigDir('opencode'),
path.join(os.homedir(), '.config', 'opencode'),
);
});
});
});
});
// ── kilo: KILO_CONFIG file-path step ─────────────────────────────────────
test('kilo: KILO_CONFIG → path.dirname(expandTilde(value))', () => {
withEnv('KILO_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', undefined, () => {
withEnv('KILO_CONFIG', '/home/u/cfg/kilo.json', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), '/home/u/cfg');
});
});
});
});
test('kilo: KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
withEnv('KILO_CONFIG_DIR', '/dir/wins', () => {
withEnv('KILO_CONFIG', '/file/loses.json', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), '/dir/wins');
});
});
});
test('kilo: KILO_CONFIG takes precedence over XDG_CONFIG_HOME', () => {
withEnv('KILO_CONFIG_DIR', undefined, () => {
withEnv('KILO_CONFIG', '/cfg/kilo.json', () => {
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), '/cfg');
});
});
});
});
test('kilo: default ~/.config/kilo when no env vars set', () => {
withEnv('KILO_CONFIG_DIR', undefined, () => {
withEnv('KILO_CONFIG', undefined, () => {
withEnv('XDG_CONFIG_HOME', undefined, () => {
assert.strictEqual(
getGlobalConfigDir('kilo'),
path.join(os.homedir(), '.config', 'kilo'),
);
});
});
});
});
});
describe('bug #3126: init.cjs uses runtime-homes not hardcoded .claude', () => {
test('init.cjs has no hardcoded globalSkillsBase assignment to ~/.claude/skills', () => {
const fs = require('node:fs');

View File

@@ -0,0 +1,133 @@
// allow-test-rule: source-text-is-the-product
// Runtime prompt/hook files are deployed verbatim — their text IS what the
// runtime loads and executes. Asserting that text carries no retired `gsd-sdk`
// reference tests the deployed contract, which no behavioral seam can observe
// (there is no runtime API that enumerates "did any shipped prompt name the
// removed SDK binary").
/**
* Regression guard: no `gsd-sdk` references in runtime-facing surfaces (#339).
*
* The `@opengsd/gsd-sdk` package and its `gsd-sdk` binary were retired (ADR 0174,
* #191). The bulk runtime cleanup is already done — this test locks it in so a
* `gsd-sdk` / `GSD_SDK` reference cannot creep back into a shipped prompt or hook
* and re-introduce drift between the documented surface and the supported
* `gsd-tools` binary.
*
* Scope: runtime surfaces only — the prompts and hooks the installer ships into
* a user's runtime config dir. Explicitly NOT covered here:
* - `bin/install.js` — installer code, not a runtime-deployed prompt/hook
* surface. (It carries zero `gsd-sdk` references today; the SDK-shim
* verification subsystem was removed in #515 and the shim retired in #522.)
* - `gsd-core/bin/` — executable library code, not deployed prompt text; it
* may legitimately reference the SDK retirement in comments.
* - `tests/`, `docs/`, `.changeset/`, CI/lint scripts — legitimately reference
* the SDK retirement as history or detect its stale artifacts.
*
* Complements `tests/gsd-tools-path-refs.test.cjs`, which only catches the
* `gsd-sdk query` binary-invocation form; this catches ANY runtime reference.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const REPO_ROOT = path.join(__dirname, '..');
// Runtime surfaces the installer ships. Each entry is { dir, exts } — dir is
// repo-relative, exts is the set of file extensions whose text is deployed.
const RUNTIME_SURFACES = [
// .md prompts plus the non-.md runtime artifacts this dir also ships:
// _runtime-launcher.snippet.sh (the canonical launcher synced into every hook
// by scripts/sync-runtime-launcher.cjs) and discuss-phase/templates/*.json
// (loaded at runtime by discuss-phase.md). Scanning only .md left these two
// deployed files uncovered. (#691 review)
{ dir: path.join('gsd-core', 'workflows'), exts: ['.md', '.sh', '.json'] },
{ dir: path.join('gsd-core', 'references'), exts: ['.md'] },
// Prompt surfaces the installer deep-copies and the runtime loads via
// `@~/.claude/gsd-core/templates/*.md` anchors in workflows/commands; the
// lone config.json under templates/ ships too. (#691 review)
{ dir: path.join('gsd-core', 'templates'), exts: ['.md', '.json'] },
{ dir: path.join('gsd-core', 'contexts'), exts: ['.md'] },
{ dir: path.join('commands', 'gsd'), exts: ['.md'] },
{ dir: 'agents', exts: ['.md'] },
// Hooks ship as executable text (.js/.cjs/.sh). `hooks/dist/` is a gitignored
// build artifact regenerated from these sources, so scanning the sources is
// sufficient and avoids asserting against generated copies.
{ dir: 'hooks', exts: ['.js', '.cjs', '.sh'], skipDirs: ['dist'] },
];
// Matches every casing/separator variant of the retired SDK token:
// gsd-sdk, gsd_sdk, GSD-SDK, GSD_SDK, etc.
const SDK_REF = /gsd[-_]sdk/i;
/**
* Recursively collect files under `absDir` whose extension is in `exts`,
* skipping any directory name listed in `skipDirs`.
*/
function collectFiles(absDir, exts, skipDirs) {
if (!fs.existsSync(absDir)) return [];
const out = [];
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (skipDirs.includes(entry.name)) continue;
out.push(...collectFiles(path.join(absDir, entry.name), exts, skipDirs));
} else if (entry.isFile() && exts.includes(path.extname(entry.name))) {
out.push(path.join(absDir, entry.name));
}
}
return out;
}
function rel(file) {
return path.relative(REPO_ROOT, file).split(path.sep).join('/');
}
describe('#339 no gsd-sdk references in runtime surfaces', () => {
test('shipped prompts and hooks carry no retired gsd-sdk reference', () => {
const violations = [];
for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) {
const files = collectFiles(path.join(REPO_ROOT, dir), exts, skipDirs);
for (const file of files) {
const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (SDK_REF.test(lines[i])) {
violations.push(`${rel(file)}:${i + 1}: ${lines[i].trim()}`);
}
}
}
}
assert.strictEqual(
violations.length,
0,
'Runtime surfaces must not reference the retired gsd-sdk binary/package — ' +
'use gsd-tools instead.\nViolations:\n' + violations.join('\n')
);
});
test('at least one file per configured extension is scanned (guards against an empty sweep)', () => {
// A path typo, directory rename, or stale extension could silently make
// collectFiles() return [] for part of a surface, turning the guard above
// into a no-op that always passes. Checking per-surface isn't enough: for
// gsd-core/workflows the .md files alone keep a per-surface count > 0, so
// dropping .sh/.json would stop covering _runtime-launcher.snippet.sh and
// discuss-phase/templates/*.json while the test stayed green. Assert each
// configured extension actually resolves to scanned files. (#691 review)
for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) {
for (const ext of exts) {
const count = collectFiles(path.join(REPO_ROOT, dir), [ext], skipDirs).length;
assert.ok(
count > 0,
`Runtime surface "${dir}" resolved to 0 "${ext}" files — the path may ` +
'have moved or the extension is stale; update RUNTIME_SURFACES so the ' +
'guard keeps covering it.'
);
}
}
});
});

View File

@@ -4,9 +4,11 @@ const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const ROOT = path.join(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'ci-test-scope.cjs');
const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
function scopeFor(files) {
const r = spawnSync(process.execPath, [SCRIPT, '--files', files.join(' ')], {
@@ -18,25 +20,124 @@ function scopeFor(files) {
}
describe('ci-test-scope.cjs', () => {
test('docs-only changes mark code_changed and select docs-parity (new correct contract)', () => {
test('docs-only changes: code_changed is false, product_changed false (skip matrix entirely)', () => {
const result = scopeFor(['docs/usage.md']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.code_changed, false,
`expected code_changed=false for docs-only change, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, false,
`expected product_changed=false for docs-only change, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, false);
// docs-parity is NOT in targeted_tests when docs-only (it runs via docs-required.yml instead)
assert.ok(
result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
`expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`,
!result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
`docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`,
);
});
test('workflow changes request full matrix and workflow contract tests', () => {
test('root markdown only: code_changed is false, product_changed false', () => {
const result = scopeFor(['README.md']);
assert.strictEqual(result.code_changed, false,
`expected code_changed=false for root markdown, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, false,
`expected product_changed=false for root markdown, got: ${JSON.stringify(result)}`);
});
test('pipeline workflow (test.yml) — product_changed true, full_matrix true, workflow contract tests', () => {
const result = scopeFor(['.github/workflows/test.yml']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.product_changed, true,
`expected product_changed=true for test.yml, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, true);
assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs'));
assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke-workflow.test.cjs'));
assert.ok(result.windows_tests.includes('tests/workflow-shell-pinning.test.cjs'));
});
test('pipeline workflow (install-smoke.yml) — product_changed true, full_matrix true', () => {
const result = scopeFor(['.github/workflows/install-smoke.yml']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.product_changed, true,
`expected product_changed=true for install-smoke.yml, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, true);
});
test('inert CI only (stale.yml) — code_changed true, product_changed false, full_matrix false', () => {
const result = scopeFor(['.github/workflows/stale.yml']);
assert.strictEqual(result.code_changed, true,
`expected code_changed=true for inert CI, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, false,
`expected product_changed=false for inert CI, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, false,
`expected full_matrix=false for inert CI, got: ${JSON.stringify(result)}`);
assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs'),
`expected workflow-shell-pinning in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'),
`expected policy-lint-shallow-checkout in targeted_tests for inert CI, got: ${JSON.stringify(result.targeted_tests)}`);
});
test('TS runtime sources (src/semver.cts) — code_changed true, product_changed true, full_matrix false, semver tests targeted', () => {
const result = scopeFor(['src/semver.cts']);
assert.strictEqual(result.code_changed, true,
`expected code_changed=true for src/ change, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, true,
`expected product_changed=true for src/ change, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, false,
`expected full_matrix=false for src/-only change (TS runtime sources rule has no fullMatrix), got: ${JSON.stringify(result)}`);
assert.ok(result.targeted_tests.includes('tests/semver-compare.test.cjs'),
`expected semver-compare in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
});
test('product code (gsd-core/bin/lib/foo.cjs) — product_changed true', () => {
const result = scopeFor(['gsd-core/bin/lib/foo.cjs']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.product_changed, true,
`expected product_changed=true for gsd-core/ change, got: ${JSON.stringify(result)}`);
});
test('unknown/new workflow defaults to pipeline (fail-safe) — product_changed true', () => {
const result = scopeFor(['.github/workflows/brand-new-thing.yml']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.product_changed, true,
`expected product_changed=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, true,
`expected full_matrix=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`);
});
test('mixed docs + code — escalates to product_changed true', () => {
// Use bin/gsd (installer rule, fullMatrix:true) to get a code file that reliably triggers full matrix.
const result = scopeFor(['docs/x.md', 'bin/gsd']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.product_changed, true,
`expected product_changed=true for docs+code, got: ${JSON.stringify(result)}`);
});
test('inert CI (docs-required.yml) — includes shallow-checkout policy test, product_changed false', () => {
const result = scopeFor(['.github/workflows/docs-required.yml']);
assert.strictEqual(result.code_changed, true,
`expected code_changed=true for docs-required.yml, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, false,
`expected product_changed=false for docs-required.yml, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, false,
`expected full_matrix=false for docs-required.yml, got: ${JSON.stringify(result)}`);
assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'),
`expected policy-lint-shallow-checkout in targeted_tests for docs-required.yml, got: ${JSON.stringify(result.targeted_tests)}`);
});
test('mixed docs + inert CI — code_changed true, product_changed false (inert lane)', () => {
const result = scopeFor(['docs/x.md', '.github/workflows/stale.yml']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.product_changed, false,
`expected product_changed=false for docs+inert, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, false);
});
test('mixed docs + src — product_changed true', () => {
const result = scopeFor(['docs/x.md', 'src/semver.cts']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.product_changed, true,
`expected product_changed=true for docs+src, got: ${JSON.stringify(result)}`);
});
test('command changes request command tests without full parity matrix', () => {
const result = scopeFor(['commands/gsd/plan-phase.md']);
assert.strictEqual(result.code_changed, true);
@@ -54,6 +155,8 @@ describe('ci-test-scope.cjs', () => {
test('installer-sensitive changes request full matrix and install tests', () => {
const result = scopeFor(['bin/gsd']);
assert.strictEqual(result.code_changed, true);
assert.strictEqual(result.product_changed, true,
`expected product_changed=true for bin/gsd, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, true);
assert.ok(result.targeted_tests.includes('tests/install.test.cjs'));
assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs'));
@@ -120,25 +223,27 @@ describe('ci-test-scope superset invariant (#494)', () => {
`expected full_matrix=true for tests/** change, got: ${JSON.stringify(result)}`);
});
// Facet B: docs/**, commands/**, agents/** → code_changed AND docs-parity selected
test('B1: docs/adr change marks code_changed and selects docs-parity-live-registry', () => {
// Facet B: commands/**, agents/** → code_changed AND docs-parity selected
// docs/ is NO LONGER in this facet — docs-only PRs skip the matrix entirely.
test('B1: docs/adr change: code_changed is false (docs skip matrix)', () => {
const result = scopeFor(['docs/adr/22-plan-drift-guard.md']);
assert.strictEqual(result.code_changed, true,
`expected code_changed=true for docs/** change, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.code_changed, false,
`expected code_changed=false for docs/** change (matrix skip), got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, false,
`expected product_changed=false for docs/** change, got: ${JSON.stringify(result)}`);
// docs-parity is NOT in targeted_tests (handled by docs-required.yml)
assert.ok(
result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
`expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`,
!result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
`docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`,
);
});
test('B2: docs locale dir change marks code_changed and selects docs-parity-live-registry', () => {
test('B2: docs locale dir change: code_changed is false (docs skip matrix)', () => {
const result = scopeFor(['docs/ja-JP/USAGE.md']);
assert.strictEqual(result.code_changed, true,
`expected code_changed=true for docs/ja-JP/** change, got: ${JSON.stringify(result)}`);
assert.ok(
result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
`expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`,
);
assert.strictEqual(result.code_changed, false,
`expected code_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, false,
`expected product_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`);
});
test('B3: commands/** change selects docs-parity-live-registry', () => {
@@ -149,3 +254,138 @@ describe('ci-test-scope superset invariant (#494)', () => {
);
});
});
describe('INERT_WORKFLOWS allowlist integrity guard', () => {
// Load the INERT_WORKFLOWS set from the script by spawning it and using --files
// on a sentinel path, then separately verify the set contents via the filesystem.
// Known pipeline workflows that MUST NOT appear in INERT_WORKFLOWS.
// Must stay in sync with PROTECTED_WORKFLOWS in scripts/ci-test-scope.cjs.
const KNOWN_PIPELINE = [
'test.yml',
'install-smoke.yml',
'mutation.yml',
'security-scan.yml',
'release.yml',
];
// Canonical inert workflow list — reused by both tests below.
const knownInert = [
'stale.yml', 'branch-cleanup.yml', 'branch-naming.yml', 'auto-label-issues.yml',
'auto-branch.yml', 'auto-backmerge.yml', 'close-draft-prs.yml',
'dismiss-unauthorized-pr-approvals.yml', 'pr-gate.yml', 'pr-target-validator.yml',
'pr-template-format.yml', 'require-issue-link.yml', 'changeset-required.yml',
'docs-required.yml', 'discord-changelog.yml',
];
test('all entries in INERT_WORKFLOWS exist under .github/workflows/', () => {
// We derive the inert set implicitly: any .github/workflows/*.yml that produces
// full_matrix=false when passed alone is inert. We check the known inert names
// against the filesystem instead.
// The canonical list is in the script — we verify each named file exists.
for (const name of knownInert) {
const fullPath = path.join(WORKFLOWS_DIR, name);
assert.ok(
fs.existsSync(fullPath),
`INERT_WORKFLOWS entry '${name}' does not exist at ${fullPath}`,
);
}
});
test('known pipeline workflows are NOT treated as inert (product_changed true, full_matrix true)', () => {
for (const name of KNOWN_PIPELINE) {
const result = scopeFor([`.github/workflows/${name}`]);
assert.strictEqual(result.product_changed, true,
`${name} must be pipeline (product_changed=true), got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, true,
`${name} must be pipeline (full_matrix=true), got: ${JSON.stringify(result)}`);
}
});
// Explicit per-workflow guard: each of the five protected workflows must route to
// the full matrix. This documents intent and proves that PROTECTED_WORKFLOWS
// enforcement is covered end-to-end via the spawn helper.
test('all five PROTECTED_WORKFLOWS individually route to full matrix (tamper-evidence)', () => {
const protected_ = [
'test.yml',
'install-smoke.yml',
'mutation.yml',
'security-scan.yml',
'release.yml',
];
for (const name of protected_) {
const result = scopeFor([`.github/workflows/${name}`]);
assert.strictEqual(result.product_changed, true,
`PROTECTED_WORKFLOW ${name}: expected product_changed=true, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, true,
`PROTECTED_WORKFLOW ${name}: expected full_matrix=true, got: ${JSON.stringify(result)}`);
}
});
test('every inert workflow produces code_changed=true, product_changed=false, and full_matrix=false', () => {
for (const name of knownInert) {
const result = scopeFor([`.github/workflows/${name}`]);
assert.strictEqual(result.code_changed, true,
`${name}: expected code_changed=true`);
assert.strictEqual(result.product_changed, false,
`${name}: expected product_changed=false`);
assert.strictEqual(result.full_matrix, false,
`${name}: expected full_matrix=false`);
}
});
});
describe('code_changed=false implies clean output invariant', () => {
// Fix 1: when code_changed is false, full_matrix, targeted_tests, windows_tests
// must ALL be empty/false — even if a docs path coincidentally
// matches a content rule via coarse substring (e.g. path.includes('install') or
// path.includes('config')).
test('docs-only: code_changed=false → product_changed=false, full_matrix=false, empty targeted_tests', () => {
const result = scopeFor(['docs/usage.md']);
assert.strictEqual(result.code_changed, false);
assert.strictEqual(result.product_changed, false);
assert.strictEqual(result.full_matrix, false);
assert.deepStrictEqual(result.targeted_tests, []);
});
// docs/installer-migrations.md contains 'install' → would match the installer rule
// via path.includes('install'). Normalization must suppress the contradictory output.
test('docs/installer-migrations.md: code_changed=false AND product_changed=false AND full_matrix=false AND empty targeted_tests', () => {
const result = scopeFor(['docs/installer-migrations.md']);
assert.strictEqual(result.code_changed, false,
`expected code_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, false,
`expected product_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.full_matrix, false,
`expected full_matrix=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
assert.deepStrictEqual(result.targeted_tests, [],
`expected empty targeted_tests for docs/installer-migrations.md, got: ${JSON.stringify(result.targeted_tests)}`);
});
// docs/how-to/configure-model-profiles.md contains 'config' → matches configuration rule.
test('docs path matching config rule: code_changed=false → empty output (coarse-substring docs suppressed)', () => {
const result = scopeFor(['docs/how-to/configure-model-profiles.md']);
assert.strictEqual(result.code_changed, false,
`expected code_changed=false, got: ${JSON.stringify(result)}`);
assert.strictEqual(result.product_changed, false);
assert.strictEqual(result.full_matrix, false);
assert.deepStrictEqual(result.targeted_tests, []);
});
// code_changed=true must produce >= 1 targeted_test or 'unit' fallback.
test('code_changed=true implies non-empty targeted_tests', () => {
for (const files of [
['src/semver.cts'],
['bin/gsd'],
['.github/workflows/test.yml'],
['.github/workflows/stale.yml'],
]) {
const result = scopeFor(files);
assert.strictEqual(result.code_changed, true,
`expected code_changed=true for ${files}, got: ${JSON.stringify(result)}`);
assert.ok(result.targeted_tests.length >= 1,
`expected >= 1 targeted_test for ${files}, got: ${JSON.stringify(result.targeted_tests)}`);
}
});
});

View File

@@ -30,20 +30,21 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
convertClaudeToCliineMarkdown,
install,
finishInstall,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
describe('Cline runtime directory mapping', () => {
test('getDirName returns .cline for local installs', () => {
assert.strictEqual(getDirName('cline'), '.cline');
});
test('getGlobalDir returns ~/.cline for global installs', () => {
assert.strictEqual(getGlobalDir('cline'), path.join(os.homedir(), '.cline'));
test('getGlobalConfigDir returns ~/.cline for global installs', () => {
assert.strictEqual(getGlobalConfigDir('cline'), path.join(os.homedir(), '.cline'));
});
test('getConfigDirFromHome returns .cline fragment', () => {
@@ -52,7 +53,7 @@ describe('Cline runtime directory mapping', () => {
});
});
describe('getGlobalDir (Cline)', () => {
describe('getGlobalConfigDir (Cline)', () => {
let originalClineConfigDir;
beforeEach(() => {
@@ -69,30 +70,30 @@ describe('getGlobalDir (Cline)', () => {
test('returns ~/.cline with no env var or explicit dir', () => {
delete process.env.CLINE_CONFIG_DIR;
const result = getGlobalDir('cline');
const result = getGlobalConfigDir('cline');
assert.strictEqual(result, path.join(os.homedir(), '.cline'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('cline', '/custom/cline-path');
const result = getGlobalConfigDir('cline', '/custom/cline-path');
assert.strictEqual(result, '/custom/cline-path');
});
test('respects CLINE_CONFIG_DIR env var', () => {
process.env.CLINE_CONFIG_DIR = '~/custom-cline';
const result = getGlobalDir('cline');
const result = getGlobalConfigDir('cline');
assert.strictEqual(result, path.join(os.homedir(), 'custom-cline'));
});
test('explicit dir takes priority over CLINE_CONFIG_DIR', () => {
process.env.CLINE_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('cline', '/explicit/path');
const result = getGlobalConfigDir('cline', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
});
});

View File

@@ -14,7 +14,6 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
convertClaudeToCodebuddyMarkdown,
convertClaudeCommandToCodebuddySkill,
@@ -25,6 +24,8 @@ const {
installRuntimeArtifacts,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
@@ -37,7 +38,7 @@ describe('CodeBuddy runtime directory mapping', () => {
});
test('maps CodeBuddy to ~/.codebuddy for global installs', () => {
assert.strictEqual(getGlobalDir('codebuddy'), path.join(os.homedir(), '.codebuddy'));
assert.strictEqual(getGlobalConfigDir('codebuddy'), path.join(os.homedir(), '.codebuddy'));
});
test('returns .codebuddy config fragments for local and global installs', () => {
@@ -46,7 +47,7 @@ describe('CodeBuddy runtime directory mapping', () => {
});
});
describe('getGlobalDir (CodeBuddy)', () => {
describe('getGlobalConfigDir (CodeBuddy)', () => {
let originalCodebuddyConfigDir;
beforeEach(() => {
@@ -63,30 +64,30 @@ describe('getGlobalDir (CodeBuddy)', () => {
test('returns ~/.codebuddy with no env var or explicit dir', () => {
delete process.env.CODEBUDDY_CONFIG_DIR;
const result = getGlobalDir('codebuddy');
const result = getGlobalConfigDir('codebuddy');
assert.strictEqual(result, path.join(os.homedir(), '.codebuddy'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('codebuddy', '/custom/codebuddy-path');
const result = getGlobalConfigDir('codebuddy', '/custom/codebuddy-path');
assert.strictEqual(result, '/custom/codebuddy-path');
});
test('respects CODEBUDDY_CONFIG_DIR env var', () => {
process.env.CODEBUDDY_CONFIG_DIR = '~/custom-codebuddy';
const result = getGlobalDir('codebuddy');
const result = getGlobalConfigDir('codebuddy');
assert.strictEqual(result, path.join(os.homedir(), 'custom-codebuddy'));
});
test('explicit dir takes priority over CODEBUDDY_CONFIG_DIR', () => {
process.env.CODEBUDDY_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('codebuddy', '/explicit/path');
const result = getGlobalConfigDir('codebuddy', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
});
});

View File

@@ -28,7 +28,6 @@ const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
claudeToCopilotTools,
convertCopilotToolName,
@@ -48,6 +47,8 @@ const {
buildRuntimePromptText,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
@@ -70,9 +71,9 @@ describe('getDirName (Copilot)', () => {
});
});
// ─── getGlobalDir ───────────────────────────────────────────────────────────────
// ─── getGlobalConfigDir ──────────────────────────────────────────────────────────
describe('getGlobalDir (Copilot)', () => {
describe('getGlobalConfigDir (Copilot)', () => {
let originalCopilotConfigDir;
beforeEach(() => {
@@ -89,30 +90,30 @@ describe('getGlobalDir (Copilot)', () => {
test('returns ~/.copilot with no env var or explicit dir', () => {
delete process.env.COPILOT_CONFIG_DIR;
const result = getGlobalDir('copilot');
const result = getGlobalConfigDir('copilot');
assert.strictEqual(result, path.join(os.homedir(), '.copilot'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('copilot', '/custom/path');
const result = getGlobalConfigDir('copilot', '/custom/path');
assert.strictEqual(result, '/custom/path');
});
test('respects COPILOT_CONFIG_DIR env var', () => {
process.env.COPILOT_CONFIG_DIR = '~/custom-copilot';
const result = getGlobalDir('copilot');
const result = getGlobalConfigDir('copilot');
assert.strictEqual(result, path.join(os.homedir(), 'custom-copilot'));
});
test('explicit dir takes priority over COPILOT_CONFIG_DIR', () => {
process.env.COPILOT_CONFIG_DIR = '~/env-path';
const result = getGlobalDir('copilot', '/explicit/path');
const result = getGlobalConfigDir('copilot', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break existing runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
});
});

View File

@@ -5,7 +5,7 @@
/**
* Installer Module — Sections 1–5.
*
* Covers: getDirName/getGlobalDir/getConfigDirFromHome, per-runtime
* Covers: getDirName/getGlobalConfigDir/getConfigDirFromHome, per-runtime
* install/uninstall spot-checks (hermes/qwen/trae), uninstall skills
* cleanup, Claude-reference leak tests, and Kilo-specific helpers.
*
@@ -34,7 +34,6 @@ const pkg = require('../package.json');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
install,
uninstall,
@@ -46,13 +45,15 @@ const {
configureKiloPermissions,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
const {
RUNTIME_META,
stripAnsi,
walk,
} = require('./helpers/install-shared.cjs');
// ─── Section 1: getDirName / getGlobalDir / getConfigDirFromHome ──────────────
// ─── Section 1: getDirName / getGlobalConfigDir / getConfigDirFromHome ──────────
describe('getDirName — all runtimes', () => {
for (const runtime of allRuntimes) {
@@ -64,11 +65,14 @@ describe('getDirName — all runtimes', () => {
}
});
describe('getGlobalDir — all runtimes default paths', () => {
describe('getGlobalConfigDir — all runtimes default paths', () => {
// Test the default (no env var, no explicit dir) for each runtime
const ENV_KEYS = [
'HERMES_HOME', 'QWEN_CONFIG_DIR', 'TRAE_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR',
'KILO_CONFIG_DIR', 'KILO_CONFIG', 'XDG_CONFIG_HOME',
'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'GEMINI_CONFIG_DIR', 'CODEX_HOME',
'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR',
'TRAE_CONFIG_DIR', 'QWEN_CONFIG_DIR', 'HERMES_HOME', 'CODEBUDDY_CONFIG_DIR',
'CLINE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR',
'KILO_CONFIG', 'ANTIGRAVITY_CONFIG_DIR', 'XDG_CONFIG_HOME',
];
let savedEnv = {};
@@ -86,15 +90,15 @@ describe('getGlobalDir — all runtimes default paths', () => {
}
});
for (const runtime of allRuntimes) {
test(`getGlobalDir('${runtime}') returns expected home-relative path`, () => {
for (const runtime of allRuntimes.filter(runtime => runtime !== 'kimi')) {
test(`getGlobalConfigDir('${runtime}') returns expected home-relative path`, () => {
const expected = path.join(os.homedir(), RUNTIME_META[runtime].globalSuffix);
assert.strictEqual(getGlobalDir(runtime), expected);
assert.strictEqual(getGlobalConfigDir(runtime), expected);
});
}
});
describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection', () => {
describe('getGlobalConfigDir/getConfigDirFromHome — antigravity 2.x layout detection', () => {
const saved = {};
beforeEach(() => {
saved.HOME = process.env.HOME;
@@ -118,7 +122,7 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalDir('antigravity'),
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity-ide'),
);
assert.strictEqual(
@@ -137,7 +141,7 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalDir('antigravity'),
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity-cli'),
);
assert.strictEqual(
@@ -150,12 +154,12 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
});
});
describe('getGlobalDir — explicit configDir overrides env for all runtimes', () => {
describe('getGlobalConfigDir — explicit configDir overrides env for all runtimes', () => {
test('explicit dir overrides any env var for hermes', () => {
const savedHome = process.env.HERMES_HOME;
process.env.HERMES_HOME = '~/from-env';
try {
assert.strictEqual(getGlobalDir('hermes', '/explicit/hermes'), '/explicit/hermes');
assert.strictEqual(getGlobalConfigDir('hermes', '/explicit/hermes'), '/explicit/hermes');
} finally {
if (savedHome !== undefined) process.env.HERMES_HOME = savedHome;
else delete process.env.HERMES_HOME;
@@ -166,7 +170,7 @@ describe('getGlobalDir — explicit configDir overrides env for all runtimes', (
const saved = process.env.KILO_CONFIG_DIR;
process.env.KILO_CONFIG_DIR = '~/from-env';
try {
assert.strictEqual(getGlobalDir('kilo', '/explicit/kilo'), '/explicit/kilo');
assert.strictEqual(getGlobalConfigDir('kilo', '/explicit/kilo'), '/explicit/kilo');
} finally {
if (saved !== undefined) process.env.KILO_CONFIG_DIR = saved;
else delete process.env.KILO_CONFIG_DIR;
@@ -174,7 +178,7 @@ describe('getGlobalDir — explicit configDir overrides env for all runtimes', (
});
});
describe('getGlobalDir — HERMES_HOME env var', () => {
describe('getGlobalConfigDir — HERMES_HOME env var', () => {
let saved;
beforeEach(() => { saved = process.env.HERMES_HOME; });
afterEach(() => {
@@ -184,11 +188,11 @@ describe('getGlobalDir — HERMES_HOME env var', () => {
test('respects HERMES_HOME env var (tilde-expanded)', () => {
process.env.HERMES_HOME = '~/custom-hermes';
assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
assert.strictEqual(getGlobalConfigDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
});
});
describe('getGlobalDir — Kilo env var priority', () => {
describe('getGlobalConfigDir — Kilo env var priority', () => {
let savedEnv;
beforeEach(() => {
savedEnv = {
@@ -209,23 +213,23 @@ describe('getGlobalDir — Kilo env var priority', () => {
test('respects KILO_CONFIG_DIR', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
test('falls back to XDG_CONFIG_HOME/kilo', () => {
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
});
test('uses dirname(KILO_CONFIG) when KILO_CONFIG_DIR unset', () => {
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'profiles', 'work'));
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'profiles', 'work'));
});
test('KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
});

View File

@@ -0,0 +1,373 @@
'use strict';
/**
* Regression tests for issue #766: additive Claude Code plugin manifest.
*
* Asserts structural and semantic correctness of:
* .claude-plugin/plugin.json — plugin manifest
* hooks/hooks.json — plugin hook wiring
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
const pkg = require(path.join(ROOT, 'package.json'));
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
// ─── Section A: plugin.json ───────────────────────────────────────────────────
describe('A: .claude-plugin/plugin.json', () => {
let manifest;
test('exists and is valid JSON', () => {
assert.ok(fs.existsSync(PLUGIN_JSON_PATH), '.claude-plugin/plugin.json must exist');
const raw = fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8');
manifest = JSON.parse(raw); // throws on invalid JSON
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
});
test('name equals identity.binName ("gsd-core")', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`);
});
test('name is kebab-case, no colons, spaces, or uppercase', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.match(
manifest.name,
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
'name must be kebab-case (no colon, space, or uppercase) to be namespace-safe'
);
});
test('version matches package.json version', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.version, pkg.version, `.claude-plugin/plugin.json version (${manifest.version}) must match package.json version (${pkg.version}). When bumping the package version, update .claude-plugin/plugin.json \`version\` to match — Claude Code plugin --strict validation requires a version field and the plugin manifest must track the package version. (#766)`);
});
test('repository equals identity.repoUrl', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.repository, identity.repoUrl, 'repository must equal identity.repoUrl');
});
test('homepage equals identity.repoUrl', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.homepage, identity.repoUrl, 'homepage must equal identity.repoUrl');
});
test('license matches package.json license', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.license, pkg.license, 'license must match package.json');
});
test('author.name is a non-empty string', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.ok(
manifest.author && typeof manifest.author.name === 'string' && manifest.author.name.trim().length > 0,
'author.name must be a non-empty string'
);
});
test('commands field is "./commands/gsd/" and that dir exists with at least one .md file', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.commands, './commands/gsd/', 'commands must be "./commands/gsd/"');
const resolvedDir = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.commands);
assert.ok(fs.existsSync(resolvedDir), `resolved commands dir must exist: ${resolvedDir}`);
const mdFiles = fs.readdirSync(resolvedDir).filter(f => f.endsWith('.md'));
assert.ok(mdFiles.length > 0, `commands dir must contain at least one .md file`);
});
test('hooks field is "./hooks/hooks.json" and that file exists', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.equal(manifest.hooks, './hooks/hooks.json', 'hooks must be "./hooks/hooks.json"');
const resolvedHooks = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.hooks);
assert.ok(fs.existsSync(resolvedHooks), `resolved hooks file must exist: ${resolvedHooks}`);
});
test('no "$schema" key (intentionally omitted)', (t) => {
if (!manifest) { t.skip('manifest could not be parsed'); return; }
assert.ok(!Object.prototype.hasOwnProperty.call(manifest, '$schema'), 'plugin.json must NOT contain a $schema key');
});
});
// ─── Section B: hooks/hooks.json ─────────────────────────────────────────────
describe('B: hooks/hooks.json', () => {
let hooksConfig;
test('exists and is valid JSON with top-level "hooks" object', () => {
assert.ok(fs.existsSync(HOOKS_JSON_PATH), 'hooks/hooks.json must exist');
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
hooksConfig = JSON.parse(raw);
assert.ok(
typeof hooksConfig === 'object' && hooksConfig !== null &&
typeof hooksConfig.hooks === 'object' && hooksConfig.hooks !== null,
'hooks.json must have a top-level "hooks" object'
);
});
test('every event name is one of: SessionStart, PreToolUse, PostToolUse', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
const validEvents = new Set(['SessionStart', 'PreToolUse', 'PostToolUse']);
for (const eventName of Object.keys(hooksConfig.hooks)) {
assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`);
}
});
test('every hook entry has type "command" and command contains ${CLAUDE_PLUGIN_ROOT}', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
assert.ok(Array.isArray(eventEntries), `Event "${eventName}" must be an array`);
for (const entry of eventEntries) {
assert.ok(Array.isArray(entry.hooks), `Entry in "${eventName}" must have a hooks array`);
for (const hook of entry.hooks) {
assert.equal(hook.type, 'command', `All hook entries must have type "command" (got "${hook.type}")`);
assert.ok(
typeof hook.command === 'string' && hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
`Hook command must contain "\${CLAUDE_PLUGIN_ROOT}": ${hook.command}`
);
}
}
}
});
test('every referenced script file exists on disk and its basename is in MANAGED_HOOKS', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
// Extract script path: substring after ${CLAUDE_PLUGIN_ROOT}/ up to next "
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)/g;
const allScripts = [];
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
const matches = [...hook.command.matchAll(scriptPathRe)];
for (const m of matches) {
allScripts.push(m[1]);
}
}
}
}
assert.ok(allScripts.length > 0, 'Should have found at least one script path in hooks.json');
for (const scriptPath of allScripts) {
const fullPath = path.join(ROOT, scriptPath);
assert.ok(fs.existsSync(fullPath), `Script referenced in hooks.json does not exist on disk: ${fullPath}`);
const basename = path.basename(scriptPath);
assert.ok(
MANAGED_HOOKS.includes(basename),
`Script basename "${basename}" is not listed in hooks/managed-hooks-registry.cjs MANAGED_HOOKS`
);
}
});
test('all six always-on hooks are wired', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
const REQUIRED_HOOKS = [
'gsd-check-update.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-worktree-path-guard.js',
'gsd-context-monitor.js',
'gsd-read-injection-scanner.js',
];
// Collect all basenames wired in hooks.json
const wiredBasenames = new Set();
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)/g;
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
const matches = [...hook.command.matchAll(scriptPathRe)];
for (const m of matches) {
wiredBasenames.add(m[1]);
}
}
}
}
for (const required of REQUIRED_HOOKS) {
assert.ok(wiredBasenames.has(required), `Required hook "${required}" is not wired in hooks/hooks.json`);
}
});
test('gsd-context-monitor.js entry has timeout === 10', (t) => {
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
let found = false;
for (const eventEntries of Object.values(hooksConfig.hooks)) {
for (const entry of eventEntries) {
for (const hook of entry.hooks) {
if (hook.command && hook.command.includes('gsd-context-monitor.js')) {
found = true;
assert.equal(hook.timeout, 10, 'gsd-context-monitor.js must have timeout === 10');
}
}
}
}
assert.ok(found, 'gsd-context-monitor.js entry was not found in hooks.json');
});
});
// ─── Section C: Optional CLI integration test ─────────────────────────────────
describe('C: claude plugin validate (CLI integration)', () => {
const claudeAvailable = (() => {
try {
const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
return result.status === 0;
} catch (_) {
return false;
}
})();
test(
'claude plugin validate . --strict exits 0 (skip if claude not on PATH)',
{ skip: !claudeAvailable ? 'claude binary not available on PATH' : false },
() => {
const result = spawnSync('claude', ['plugin', 'validate', '.', '--strict'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 15000,
});
assert.equal(
result.status,
0,
`claude plugin validate . --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
);
});
// ─── Section D: Always-on hook contract (drift guard) ────────────────────────
describe('D: always-on hook contract drift guard', () => {
/**
* Parses hooks.json and builds a map:
* event -> matcher (or '' for no-matcher) -> [{script, timeout}]
*
* script: basename of the .js/.sh file referenced in the command string
* timeout: numeric value from hook.timeout, or undefined if absent
*/
function buildHookMap() {
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
const hooksConfig = JSON.parse(raw);
const scriptRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^\s"]+)/;
const map = {};
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
map[eventName] = map[eventName] || {};
for (const entry of eventEntries) {
const matcher = entry.matcher || '';
map[eventName][matcher] = map[eventName][matcher] || [];
for (const hook of entry.hooks) {
const m = hook.command.match(scriptRe);
if (m) {
map[eventName][matcher].push({
script: m[1],
timeout: hook.timeout,
});
}
}
}
}
return map;
}
test('SessionStart: exactly one no-matcher group with gsd-check-update.js and no timeout', () => {
const map = buildHookMap();
const groups = map['SessionStart'];
assert.ok(groups, 'SessionStart must be present in hooks.json');
// There must be exactly one entry group (key '' = no matcher)
const noMatcherHooks = groups[''];
assert.ok(
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 1,
`SessionStart no-matcher group must contain exactly one hook; got: ${JSON.stringify(noMatcherHooks)}`
);
const h = noMatcherHooks[0];
assert.equal(h.script, 'gsd-check-update.js', 'SessionStart hook must be gsd-check-update.js');
assert.equal(h.timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
});
test('PreToolUse Write|Edit group: gsd-prompt-guard.js (timeout 5) + gsd-read-guard.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PreToolUse'];
assert.ok(groups, 'PreToolUse must be present in hooks.json');
const hooks = groups['Write|Edit'];
assert.ok(
Array.isArray(hooks) && hooks.length === 2,
`PreToolUse Write|Edit must have exactly 2 hooks; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-prompt-guard.js', 'first hook must be gsd-prompt-guard.js');
assert.equal(hooks[0].timeout, 5, 'gsd-prompt-guard.js must have timeout 5');
assert.equal(hooks[1].script, 'gsd-read-guard.js', 'second hook must be gsd-read-guard.js');
assert.equal(hooks[1].timeout, 5, 'gsd-read-guard.js must have timeout 5');
});
test('PreToolUse Write|Edit|MultiEdit group: gsd-worktree-path-guard.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PreToolUse'];
assert.ok(groups, 'PreToolUse must be present in hooks.json');
const hooks = groups['Write|Edit|MultiEdit'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PreToolUse Write|Edit|MultiEdit must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-worktree-path-guard.js', 'hook must be gsd-worktree-path-guard.js');
assert.equal(hooks[0].timeout, 5, 'gsd-worktree-path-guard.js must have timeout 5');
});
test('PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task group: gsd-context-monitor.js (timeout 10)', () => {
const map = buildHookMap();
const groups = map['PostToolUse'];
assert.ok(groups, 'PostToolUse must be present in hooks.json');
const hooks = groups['Bash|Edit|Write|MultiEdit|Agent|Task'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-context-monitor.js', 'hook must be gsd-context-monitor.js');
assert.equal(hooks[0].timeout, 10, 'gsd-context-monitor.js must have timeout 10');
});
test('PostToolUse Read group: gsd-read-injection-scanner.js (timeout 5)', () => {
const map = buildHookMap();
const groups = map['PostToolUse'];
assert.ok(groups, 'PostToolUse must be present in hooks.json');
const hooks = groups['Read'];
assert.ok(
Array.isArray(hooks) && hooks.length === 1,
`PostToolUse Read must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
);
assert.equal(hooks[0].script, 'gsd-read-injection-scanner.js', 'hook must be gsd-read-injection-scanner.js');
assert.equal(hooks[0].timeout, 5, 'gsd-read-injection-scanner.js must have timeout 5');
});
});
// ─── Section E: Config-gated hooks must be absent from hooks.json ─────────────
describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => {
const CONFIG_GATED_HOOKS = [
'gsd-workflow-guard.js',
'gsd-validate-commit.sh',
'gsd-graphify-update.sh',
'gsd-session-state.sh',
'gsd-phase-boundary.sh',
'gsd-update-banner.js',
'gsd-statusline.js',
'gsd-check-update-worker.js',
];
test('none of the config-gated hook basenames appear in hooks.json command strings', () => {
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
// Check raw text — simple and resistant to structure changes
for (const hookBasename of CONFIG_GATED_HOOKS) {
assert.ok(
!raw.includes(hookBasename),
`Config-gated hook "${hookBasename}" must NOT appear in hooks/hooks.json ` +
`(it is opt-in and must not run unconditionally on the plugin path)`
);
}
});
});

View File

@@ -0,0 +1,251 @@
'use strict';
// Tests for runtime-config-adapter-registry.cjs (issue #60).
// TDD: this file is written BEFORE the implementation to establish the red state.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const {
resolveRuntimeConfigIntent,
ALLOWED_CONFIG_RUNTIMES,
INSTALL_SURFACES,
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs'));
// ---------------------------------------------------------------------------
// Source-of-truth table (mirrors the intent table in the brief exactly)
// ---------------------------------------------------------------------------
const EXPECTED_TABLE = [
{ runtime: 'claude', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
{ runtime: 'gemini', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
{ runtime: 'antigravity', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
{ runtime: 'augment', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
{ runtime: 'qwen', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
{ runtime: 'hermes', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
{ runtime: 'codebuddy', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
{ runtime: 'opencode', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' },
{ runtime: 'kilo', installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' },
{ runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null },
{ runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null },
{ runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null },
{ runtime: 'cursor', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
{ runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
{ runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
{ runtime: 'kimi', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
];
// ---------------------------------------------------------------------------
// Test 1: Table-lock — every row in EXPECTED_TABLE must match exactly
// ---------------------------------------------------------------------------
describe('resolveRuntimeConfigIntent — table-lock', () => {
for (const row of EXPECTED_TABLE) {
test(`${row.runtime} resolves to expected intent`, () => {
const intent = resolveRuntimeConfigIntent(row.runtime);
assert.deepStrictEqual(intent, {
runtime: row.runtime,
installSurface: row.installSurface,
writesSharedSettings: row.writesSharedSettings,
finishPermissionWriter: row.finishPermissionWriter,
});
});
}
});
// ---------------------------------------------------------------------------
// Test 2: Unknown runtime fails loudly (AC#2)
// ---------------------------------------------------------------------------
describe('resolveRuntimeConfigIntent — unknown runtime throws TypeError', () => {
test('throws TypeError for unknown string "grok"', () => {
assert.throws(() => resolveRuntimeConfigIntent('grok'), TypeError);
});
test('throws TypeError for unknown string "xyzunknown"', () => {
assert.throws(() => resolveRuntimeConfigIntent('xyzunknown'), TypeError);
});
test('throws TypeError for empty string ""', () => {
assert.throws(() => resolveRuntimeConfigIntent(''), TypeError);
});
test('throws TypeError for undefined', () => {
assert.throws(() => resolveRuntimeConfigIntent(undefined), TypeError);
});
test('throws TypeError for "__proto__" (prototype-chain key)', () => {
assert.throws(() => resolveRuntimeConfigIntent('__proto__'), TypeError);
});
test('throws TypeError for "constructor" (prototype-chain key)', () => {
assert.throws(() => resolveRuntimeConfigIntent('constructor'), TypeError);
});
test('throws TypeError for "hasOwnProperty" (prototype-chain key)', () => {
assert.throws(() => resolveRuntimeConfigIntent('hasOwnProperty'), TypeError);
});
test('throws TypeError for "toString" (prototype-chain key)', () => {
assert.throws(() => resolveRuntimeConfigIntent('toString'), TypeError);
});
});
// ---------------------------------------------------------------------------
// Test 3: writesSharedSettings exclusion equivalence
// ---------------------------------------------------------------------------
describe('writesSharedSettings exclusion equivalence', () => {
const EXPECTED_FALSE_SET = new Set(['codex', 'copilot', 'kilo', 'cursor', 'windsurf', 'trae', 'cline', 'kimi']);
test('runtimes with writesSharedSettings===false are exactly the exclusion set', () => {
const falseRuntimes = EXPECTED_TABLE
.filter(r => r.writesSharedSettings === false)
.map(r => r.runtime);
assert.deepStrictEqual(new Set(falseRuntimes), EXPECTED_FALSE_SET);
});
test('all other supported runtimes have writesSharedSettings===true', () => {
const trueRuntimes = EXPECTED_TABLE
.filter(r => r.writesSharedSettings === true)
.map(r => r.runtime);
for (const runtime of trueRuntimes) {
assert.ok(!EXPECTED_FALSE_SET.has(runtime), `${runtime} should have writesSharedSettings true`);
}
});
});
// ---------------------------------------------------------------------------
// Test 4: finishPermissionWriter correctness
// ---------------------------------------------------------------------------
describe('finishPermissionWriter', () => {
test('opencode -> "opencode"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('opencode').finishPermissionWriter, 'opencode');
});
test('kilo -> "kilo"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('kilo').finishPermissionWriter, 'kilo');
});
test('every other supported runtime -> null', () => {
const nullExpected = EXPECTED_TABLE
.filter(r => r.finishPermissionWriter === null)
.map(r => r.runtime);
for (const runtime of nullExpected) {
assert.strictEqual(
resolveRuntimeConfigIntent(runtime).finishPermissionWriter,
null,
`${runtime} should have finishPermissionWriter null`,
);
}
});
});
// ---------------------------------------------------------------------------
// Test 5: Distinct dedicated surfaces
// ---------------------------------------------------------------------------
describe('installSurface correctness', () => {
test('codex -> "codex-toml"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('codex').installSurface, 'codex-toml');
});
test('copilot -> "copilot-instructions"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('copilot').installSurface, 'copilot-instructions');
});
test('cline -> "cline-rules"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('cline').installSurface, 'cline-rules');
});
test('cursor -> "profile-marker-only"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'profile-marker-only');
});
test('windsurf -> "profile-marker-only"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('windsurf').installSurface, 'profile-marker-only');
});
test('trae -> "profile-marker-only"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('trae').installSurface, 'profile-marker-only');
});
test('kimi -> "profile-marker-only"', () => {
assert.strictEqual(resolveRuntimeConfigIntent('kimi').installSurface, 'profile-marker-only');
});
test('the 7 passthroughs + opencode + kilo -> "settings-json"', () => {
const settingsJsonRuntimes = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', 'opencode', 'kilo'];
for (const runtime of settingsJsonRuntimes) {
assert.strictEqual(
resolveRuntimeConfigIntent(runtime).installSurface,
'settings-json',
`${runtime} should have installSurface "settings-json"`,
);
}
});
});
// ---------------------------------------------------------------------------
// Test 6: Returned intent is a fresh object (no shared reference mutation)
// ---------------------------------------------------------------------------
describe('resolveRuntimeConfigIntent — fresh object each call', () => {
test('mutating the returned object does not affect a subsequent resolve', () => {
const first = resolveRuntimeConfigIntent('claude');
first.installSurface = 'MUTATED';
first.writesSharedSettings = false;
const second = resolveRuntimeConfigIntent('claude');
assert.strictEqual(second.installSurface, 'settings-json');
assert.strictEqual(second.writesSharedSettings, true);
});
});
// ---------------------------------------------------------------------------
// Test 7: Completeness (AC#4 table-driven) — ALLOWED_CONFIG_RUNTIMES
// ---------------------------------------------------------------------------
describe('ALLOWED_CONFIG_RUNTIMES completeness', () => {
const EXPECTED_16 = new Set([
'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy',
'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae',
'kimi',
]);
test('ALLOWED_CONFIG_RUNTIMES contains exactly the 16 expected runtimes', () => {
const runtimeSet = new Set(ALLOWED_CONFIG_RUNTIMES);
assert.deepStrictEqual(runtimeSet, EXPECTED_16);
});
test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => {
for (const runtime of ALLOWED_CONFIG_RUNTIMES) {
assert.doesNotThrow(() => resolveRuntimeConfigIntent(runtime), `${runtime} should resolve without throwing`);
}
});
test('ALLOWED_CONFIG_RUNTIMES has exactly 16 entries', () => {
assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 16);
});
});
// ---------------------------------------------------------------------------
// Test 8: INSTALL_SURFACES export
// ---------------------------------------------------------------------------
describe('INSTALL_SURFACES export', () => {
const EXPECTED_SURFACES = new Set([
'settings-json',
'codex-toml',
'copilot-instructions',
'cline-rules',
'profile-marker-only',
]);
test('INSTALL_SURFACES contains exactly the 5 surface strings', () => {
assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES);
});
});

View File

@@ -5,9 +5,9 @@ const assert = require('node:assert/strict');
const os = require('os');
const path = require('path');
const { getGlobalDir } = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
describe('getGlobalDir (Windsurf)', () => {
describe('getGlobalConfigDir (Windsurf)', () => {
let originalWindsurfConfigDir;
beforeEach(() => {
@@ -24,29 +24,29 @@ describe('getGlobalDir (Windsurf)', () => {
});
test('returns ~/.codeium/windsurf with no env var or explicit dir', () => {
const result = getGlobalDir('windsurf');
const result = getGlobalConfigDir('windsurf');
assert.strictEqual(result, path.join(os.homedir(), '.codeium', 'windsurf'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('windsurf', '/custom/windsurf-path');
const result = getGlobalConfigDir('windsurf', '/custom/windsurf-path');
assert.strictEqual(result, '/custom/windsurf-path');
});
test('respects WINDSURF_CONFIG_DIR env var', () => {
process.env.WINDSURF_CONFIG_DIR = '~/custom-windsurf';
const result = getGlobalDir('windsurf');
const result = getGlobalConfigDir('windsurf');
assert.strictEqual(result, path.join(os.homedir(), 'custom-windsurf'));
});
test('explicit dir takes priority over WINDSURF_CONFIG_DIR', () => {
process.env.WINDSURF_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('windsurf', '/explicit/path');
const result = getGlobalConfigDir('windsurf', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
});
});