diff --git a/.changeset/56-retire-legacy-runtime-directory-helpers.md b/.changeset/56-retire-legacy-runtime-directory-helpers.md new file mode 100644 index 000000000..4737ef30f --- /dev/null +++ b/.changeset/56-retire-legacy-runtime-directory-helpers.md @@ -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) + + diff --git a/.changeset/60-runtime-config-adapter-registry.md b/.changeset/60-runtime-config-adapter-registry.md new file mode 100644 index 000000000..802af38a6 --- /dev/null +++ b/.changeset/60-runtime-config-adapter-registry.md @@ -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) + + diff --git a/.changeset/766-native-claude-plugin-manifest.md b/.changeset/766-native-claude-plugin-manifest.md new file mode 100644 index 000000000..9e4cdf9e7 --- /dev/null +++ b/.changeset/766-native-claude-plugin-manifest.md @@ -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:` (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. diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 000000000..68df04eb6 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" +} diff --git a/.github/workflows/docs-required.yml b/.github/workflows/docs-required.yml index a6c8a213a..4961d90d5 100644 --- a/.github/workflows/docs-required.yml +++ b/.github/workflows/docs-required.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68c46eb4e..d186c6d65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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." diff --git a/.gitignore b/.gitignore index 49990d26c..bbc12e1b1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 0a15b14ff..baea885cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` 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//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` 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`. diff --git a/bin/install.js b/bin/install.js index 17bede13c..0dc80ffd2 100755 --- a/bin/install.js +++ b/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(', '); diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e6e5a6db9..f879a60bd 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index fedcb8ab2..046b487c2 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 2f458c0b0..bb422cd18 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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-` (skills-based runtimes) and `$gsd-` (codex) in user-facing output and persisted artifacts (#3584) | diff --git a/docs/adr/766-claude-code-plugin-manifest-module.md b/docs/adr/766-claude-code-plugin-manifest-module.md new file mode 100644 index 000000000..c150f8d03 --- /dev/null +++ b/docs/adr/766-claude-code-plugin-manifest-module.md @@ -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 (`/:`), 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:`; namespacing replaces the file-copy path's `/gsd:` (an additive UX change, not a data-format break). | +| Agent surface (`agents/*.md`) | *(omitted — default `agents/` discovery)* | the explicit `agents: ` 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: . diff --git a/docs/adr/README.md b/docs/adr/README.md index 612e4891f..e91265d49 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 55074ba5f..65ed9b87e 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -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:` — for example, `/gsd-core:plan-phase`. This is distinct from the classic npm/file-copy installer, which exposes commands as `/gsd:`. 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/