Merge remote-tracking branch 'upstream/next' into kimi-runtime-support
# Conflicts: # bin/install.js
This commit is contained in:
7
.changeset/56-retire-legacy-runtime-directory-helpers.md
Normal file
7
.changeset/56-retire-legacy-runtime-directory-helpers.md
Normal 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 -->
|
||||
7
.changeset/60-runtime-config-adapter-registry.md
Normal file
7
.changeset/60-runtime-config-adapter-registry.md
Normal 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 -->
|
||||
5
.changeset/766-native-claude-plugin-manifest.md
Normal file
5
.changeset/766-native-claude-plugin-manifest.md
Normal 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.
|
||||
16
.claude-plugin/plugin.json
Normal file
16
.claude-plugin/plugin.json
Normal 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"
|
||||
}
|
||||
15
.github/workflows/docs-required.yml
vendored
15
.github/workflows/docs-required.yml
vendored
@@ -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
|
||||
|
||||
83
.github/workflows/test.yml
vendored
83
.github/workflows/test.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
287
bin/install.js
287
bin/install.js
@@ -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(', ');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
70
docs/adr/766-claude-code-plugin-manifest-module.md
Normal file
70
docs/adr/766-claude-code-plugin-manifest-module.md
Normal 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>.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
40
hooks/hooks.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"gsd-core",
|
||||
"assets",
|
||||
"agents",
|
||||
".claude-plugin",
|
||||
"hooks",
|
||||
"scripts"
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
110
src/runtime-config-adapter-registry.cts
Normal file
110
src/runtime-config-adapter-registry.cts
Normal 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 };
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
133
tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs
Normal file
133
tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
373
tests/issue-766-plugin-manifest.test.cjs
Normal file
373
tests/issue-766-plugin-manifest.test.cjs
Normal 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)`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
251
tests/runtime-config-adapter-registry.test.cjs
Normal file
251
tests/runtime-config-adapter-registry.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user