@opengsd/gsd-core 1.3.1 → 1.4.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +23 -0
- package/GEMINI.md +53 -0
- package/agents/gsd-advisor-researcher.md +1 -20
- package/agents/gsd-ai-researcher.md +2 -21
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-domain-researcher.md +2 -21
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-eval-planner.md +1 -1
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-framework-selector.md +1 -1
- package/agents/gsd-nyquist-auditor.md +1 -1
- package/agents/gsd-pattern-mapper.md +1 -1
- package/agents/gsd-phase-researcher.md +92 -166
- package/agents/gsd-planner.md +9 -36
- package/agents/gsd-project-researcher.md +62 -141
- package/agents/gsd-security-auditor.md +1 -1
- package/agents/gsd-ui-auditor.md +1 -1
- package/agents/gsd-ui-checker.md +1 -1
- package/agents/gsd-ui-researcher.md +3 -22
- package/agents/gsd-user-profiler.md +1 -1
- package/agents/gsd-verifier.md +8 -2
- package/bin/install.js +1977 -339
- package/commands/gsd/autonomous.md +2 -0
- package/commands/gsd/execute-phase.md +2 -0
- package/commands/gsd/graphify.md +11 -6
- package/commands/gsd/import.md +6 -2
- package/commands/gsd/plan-phase.md +4 -2
- package/commands/gsd/progress.md +1 -0
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/update.md +3 -2
- package/gemini-extension.json +6 -0
- package/gsd-core/bin/check-latest-version.cjs +61 -6
- package/gsd-core/bin/gsd-tools.cjs +238 -32
- package/gsd-core/bin/lib/check-command-router.cjs +1 -0
- package/gsd-core/bin/lib/cli-exit.cjs +42 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
- package/gsd-core/bin/lib/commands.cjs +5 -4
- package/gsd-core/bin/lib/config.cjs +28 -4
- package/gsd-core/bin/lib/core.cjs +72 -28
- package/gsd-core/bin/lib/graphify.cjs +2 -2
- package/gsd-core/bin/lib/init-command-router.cjs +2 -2
- package/gsd-core/bin/lib/init.cjs +19 -3
- package/gsd-core/bin/lib/install-profiles.cjs +58 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +1 -1
- package/gsd-core/bin/lib/intel.cjs +3 -20
- package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
- package/gsd-core/bin/lib/phase.cjs +3 -3
- package/gsd-core/bin/lib/research-provider.cjs +137 -0
- package/gsd-core/bin/lib/research-store.cjs +167 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +67 -9
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +56 -0
- package/gsd-core/bin/lib/runtime-homes.cjs +32 -11
- package/gsd-core/bin/lib/security.cjs +73 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +9 -0
- package/gsd-core/bin/lib/surface.cjs +54 -11
- package/gsd-core/bin/lib/validate.cjs +2 -2
- package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
- package/gsd-core/bin/lib/verification.cjs +193 -0
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
- package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
- package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
- package/gsd-core/references/planner-load-graph-context.md +36 -0
- package/gsd-core/references/planning-config.md +3 -1
- package/gsd-core/references/research-documentation-lookup.md +29 -0
- package/gsd-core/references/research-philosophy.md +29 -0
- package/gsd-core/references/research-verification-protocol.md +27 -0
- package/gsd-core/workflows/execute-phase.md +19 -8
- package/gsd-core/workflows/help/modes/full.md +4 -3
- package/gsd-core/workflows/ingest-docs.md +3 -2
- package/gsd-core/workflows/plan-phase.md +14 -10
- package/gsd-core/workflows/plan-review-convergence.md +3 -3
- package/gsd-core/workflows/review.md +24 -7
- package/gsd-core/workflows/ship.md +5 -8
- package/gsd-core/workflows/spec-phase.md +2 -1
- package/gsd-core/workflows/update.md +34 -6
- package/hooks/dist/gsd-config-reload.js +133 -0
- package/hooks/dist/gsd-context-monitor.js +1 -1
- package/hooks/dist/gsd-cursor-post-tool.js +75 -0
- package/hooks/dist/gsd-cursor-session-start.js +52 -0
- package/hooks/dist/gsd-workflow-guard.js +1 -0
- package/hooks/dist/gsd-worktree-path-guard.js +1 -1
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-config-reload.js +133 -0
- package/hooks/gsd-context-monitor.js +1 -1
- package/hooks/gsd-cursor-post-tool.js +75 -0
- package/hooks/gsd-cursor-session-start.js +52 -0
- package/hooks/gsd-workflow-guard.js +1 -0
- package/hooks/gsd-worktree-path-guard.js +1 -1
- package/hooks/hooks.json +69 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +8 -1
- package/scripts/affected-tests-lib.cjs +3 -2
- package/scripts/build-hooks.js +7 -0
- package/scripts/changeset/cli.cjs +226 -28
- package/scripts/changeset/lint.cjs +5 -4
- package/scripts/changeset/new.cjs +4 -4
- package/scripts/check-alias-drift.cjs +77 -71
- package/scripts/check-env.cjs +185 -179
- package/scripts/check-npm-integrity.cjs +115 -109
- package/scripts/ci-guard-runner.cjs +11 -5
- package/scripts/ci-prepare-test-scope.cjs +27 -22
- package/scripts/ci-rebase-check.cjs +46 -45
- package/scripts/ci-test-scope.cjs +126 -22
- package/scripts/diff-touches-shipped-paths.cjs +52 -44
- package/scripts/gen-inventory-manifest.cjs +38 -32
- package/scripts/gen-research-agents.cjs +276 -0
- package/scripts/issue-dedupe.cjs +278 -0
- package/scripts/lib/cli-exit.cjs +56 -0
- package/scripts/lint-command-contract.cjs +28 -22
- package/scripts/lint-descriptions.cjs +32 -28
- package/scripts/lint-docs-required.cjs +4 -4
- package/scripts/lint-legacy-dir-name.cjs +56 -52
- package/scripts/lint-pr-check-project-dir.cjs +3 -1
- package/scripts/lint-shell-command-projection-drift.cjs +27 -22
- package/scripts/lint-skill-deps.cjs +31 -26
- package/scripts/lint-test-file-count.allowlist.json +2 -0
- package/scripts/lint-test-file-count.cjs +5 -4
- package/scripts/mutation-matrix.cjs +6 -3
- package/scripts/prompt-injection-scan.sh +1 -1
- package/scripts/release-notes/discord-release-summary.cjs +373 -0
- package/scripts/release-notes/format-github-release-notes.cjs +8 -3
- package/scripts/release-tarball-smoke.cjs +6 -4
- package/scripts/research-profiles.cjs +149 -0
- package/scripts/run-affected-tests.cjs +2 -1
- package/scripts/run-cross-platform-tests.cjs +11 -7
- package/scripts/run-tests.cjs +8 -7
- package/scripts/strip-prose-atrefs.cjs +1 -1
- package/scripts/sync-manifest-versions.cjs +119 -0
- package/scripts/sync-runtime-launcher.cjs +0 -3
- package/scripts/verify-npm-publish.cjs +14 -26
package/bin/install.js
CHANGED
|
@@ -30,7 +30,13 @@ const {
|
|
|
30
30
|
} = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'));
|
|
31
31
|
const {
|
|
32
32
|
resolveAntigravityGlobalDir,
|
|
33
|
+
getGlobalConfigDir,
|
|
33
34
|
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
|
35
|
+
const {
|
|
36
|
+
applyWorktreeBaseRef,
|
|
37
|
+
readBaseRefFromSettings,
|
|
38
|
+
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
|
|
39
|
+
const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
42
|
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
|
|
@@ -91,10 +97,112 @@ function isCodexHooksFeatureKey(key) {
|
|
|
91
97
|
return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
// #768 \u2014 Claude Code permissions.allow / permissions.deny entries.
|
|
101
|
+
// Pre-populated during Claude installs to eliminate first-run approval friction
|
|
102
|
+
// for gsd-core's own known-safe tool calls, and to add defense-in-depth deny
|
|
103
|
+
// entries for common credential files.
|
|
104
|
+
//
|
|
105
|
+
// Format: each string uses Claude Code's documented permission rule syntax \u2014
|
|
106
|
+
// "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)"
|
|
107
|
+
// "Tool" (bare tool name, no pattern)
|
|
108
|
+
//
|
|
109
|
+
// Merge policy: additive, non-destructive \u2014 existing user entries are preserved;
|
|
110
|
+
// GSD entries are appended only when not already present (idempotent).
|
|
111
|
+
const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
|
|
112
|
+
'Bash(npx gsd-core *)',
|
|
113
|
+
'Read(.planning/*)',
|
|
114
|
+
'Write(.planning/*)',
|
|
115
|
+
'Read(STATE.md)',
|
|
116
|
+
'Write(STATE.md)',
|
|
117
|
+
]);
|
|
118
|
+
const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([
|
|
119
|
+
'Read(.env)',
|
|
120
|
+
'Read(.env.*)',
|
|
121
|
+
'Read(.secrets)',
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Merge GSD-owned permission entries into a Claude Code settings object.
|
|
126
|
+
*
|
|
127
|
+
* Additive and idempotent: existing allow/deny entries are preserved; GSD
|
|
128
|
+
* entries are appended only if not already present. No other permission sub-keys
|
|
129
|
+
* (ask, disableBypassPermissionsMode, etc.) are touched.
|
|
130
|
+
*
|
|
131
|
+
* Defensive: if settings is not a plain object, returns immediately without
|
|
132
|
+
* throwing. If permissions.allow / permissions.deny exist but are not arrays
|
|
133
|
+
* (malformed settings), they are replaced with valid arrays.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} settings - The parsed settings.json object to mutate in-place.
|
|
136
|
+
*/
|
|
137
|
+
function mergeClaudePermissions(settings) {
|
|
138
|
+
if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) return;
|
|
139
|
+
|
|
140
|
+
if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) {
|
|
141
|
+
settings.permissions = {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!Array.isArray(settings.permissions.allow)) {
|
|
145
|
+
settings.permissions.allow = [];
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(settings.permissions.deny)) {
|
|
148
|
+
settings.permissions.deny = [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) {
|
|
152
|
+
if (!settings.permissions.allow.includes(entry)) {
|
|
153
|
+
settings.permissions.allow.push(entry);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
|
|
157
|
+
if (!settings.permissions.deny.includes(entry)) {
|
|
158
|
+
settings.permissions.deny.push(entry);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
94
163
|
// Copilot instructions marker constants
|
|
95
164
|
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by gsd-core installer -->';
|
|
96
165
|
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
|
|
97
166
|
|
|
167
|
+
// #786 \u2014 GitHub Copilot CLI lifecycle hook constants.
|
|
168
|
+
// Copilot reads hook configs from <config>/hooks/*.json (repo scope: .github/hooks/,
|
|
169
|
+
// user scope: ~/.copilot/hooks/) with the shape { version, hooks: { <event>: [...] } }.
|
|
170
|
+
// Events use camelCase (sessionStart, preToolUse, postToolUse, ...). A `command`
|
|
171
|
+
// hook runs an INLINE shell command (bash / powershell), so the GSD hook is fully
|
|
172
|
+
// self-contained \u2014 there is no separate hook script to install, and therefore
|
|
173
|
+
// nothing that can dangle if a script copy is skipped. See
|
|
174
|
+
// https://docs.github.com/en/copilot/reference/hooks-configuration
|
|
175
|
+
const GSD_COPILOT_HOOK_FILE = 'gsd-session.json';
|
|
176
|
+
// Copilot parses a command hook's stdout as the hook-output JSON. For sessionStart
|
|
177
|
+
// the schema is `{ additionalContext?: string }` (the text is prepended to the
|
|
178
|
+
// session as context). So the hook must emit that JSON envelope — not bare text.
|
|
179
|
+
// The two messages contain no JSON-special characters, so they embed verbatim.
|
|
180
|
+
const GSD_COPILOT_SESSION_MSG_PRESENT =
|
|
181
|
+
'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.';
|
|
182
|
+
const GSD_COPILOT_SESSION_MSG_ABSENT =
|
|
183
|
+
'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.';
|
|
184
|
+
const GSD_COPILOT_SESSION_HOOK_BASH =
|
|
185
|
+
'if [ -f .planning/STATE.md ]; then ' +
|
|
186
|
+
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` +
|
|
187
|
+
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`;
|
|
188
|
+
const GSD_COPILOT_SESSION_HOOK_PWSH =
|
|
189
|
+
'if (Test-Path .planning/STATE.md) ' +
|
|
190
|
+
`{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
|
|
191
|
+
`else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
|
|
192
|
+
|
|
193
|
+
// #777 — Cursor CLI lifecycle hook constants.
|
|
194
|
+
// Cursor reads hook configs from <project-root>/.cursor/hooks.json (local) or
|
|
195
|
+
// ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { <event>: [...] } }.
|
|
196
|
+
// Events use camelCase: sessionStart, postToolUse, preToolUse, etc.
|
|
197
|
+
// A `command` hook entry runs an external script. GSD registers two managed hooks:
|
|
198
|
+
// sessionStart → gsd-cursor-session-start.js (context injection)
|
|
199
|
+
// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor)
|
|
200
|
+
// Cursor docs: https://cursor.com/docs/hooks
|
|
201
|
+
const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
|
|
202
|
+
const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
|
|
203
|
+
// Marker comment embedded in managed hook entries so GSD can find+remove them.
|
|
204
|
+
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
|
|
205
|
+
|
|
98
206
|
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
|
|
99
207
|
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
|
|
100
208
|
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
|
|
@@ -403,218 +511,6 @@ function getConfigDirFromHome(runtime, isGlobal) {
|
|
|
403
511
|
return "'.claude'";
|
|
404
512
|
}
|
|
405
513
|
|
|
406
|
-
/**
|
|
407
|
-
* Get the global config directory for OpenCode
|
|
408
|
-
* OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
|
|
409
|
-
* Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
|
|
410
|
-
*/
|
|
411
|
-
function getOpencodeGlobalDir() {
|
|
412
|
-
// 1. Explicit OPENCODE_CONFIG_DIR env var
|
|
413
|
-
if (process.env.OPENCODE_CONFIG_DIR) {
|
|
414
|
-
return expandTilde(process.env.OPENCODE_CONFIG_DIR);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// 2. OPENCODE_CONFIG env var (use its directory)
|
|
418
|
-
if (process.env.OPENCODE_CONFIG) {
|
|
419
|
-
return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// 3. XDG_CONFIG_HOME/opencode
|
|
423
|
-
if (process.env.XDG_CONFIG_HOME) {
|
|
424
|
-
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// 4. Default: ~/.config/opencode (XDG default)
|
|
428
|
-
return path.join(os.homedir(), '.config', 'opencode');
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* Get the global config directory for Kilo
|
|
433
|
-
* Kilo follows XDG Base Directory spec and uses ~/.config/kilo/
|
|
434
|
-
* Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo
|
|
435
|
-
*/
|
|
436
|
-
function getKiloGlobalDir() {
|
|
437
|
-
// 1. Explicit KILO_CONFIG_DIR env var
|
|
438
|
-
if (process.env.KILO_CONFIG_DIR) {
|
|
439
|
-
return expandTilde(process.env.KILO_CONFIG_DIR);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// 2. KILO_CONFIG env var (use its directory)
|
|
443
|
-
if (process.env.KILO_CONFIG) {
|
|
444
|
-
return path.dirname(expandTilde(process.env.KILO_CONFIG));
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// 3. XDG_CONFIG_HOME/kilo
|
|
448
|
-
if (process.env.XDG_CONFIG_HOME) {
|
|
449
|
-
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// 4. Default: ~/.config/kilo (XDG default)
|
|
453
|
-
return path.join(os.homedir(), '.config', 'kilo');
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Get the global config directory for a runtime
|
|
458
|
-
* @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
|
|
459
|
-
* @param {string|null} explicitDir - Explicit directory from --config-dir flag
|
|
460
|
-
*/
|
|
461
|
-
function getGlobalDir(runtime, explicitDir = null) {
|
|
462
|
-
if (runtime === 'opencode') {
|
|
463
|
-
// For OpenCode, --config-dir overrides env vars
|
|
464
|
-
if (explicitDir) {
|
|
465
|
-
return expandTilde(explicitDir);
|
|
466
|
-
}
|
|
467
|
-
return getOpencodeGlobalDir();
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (runtime === 'kilo') {
|
|
471
|
-
// For Kilo, --config-dir overrides env vars
|
|
472
|
-
if (explicitDir) {
|
|
473
|
-
return expandTilde(explicitDir);
|
|
474
|
-
}
|
|
475
|
-
return getKiloGlobalDir();
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (runtime === 'gemini') {
|
|
479
|
-
// Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
|
|
480
|
-
if (explicitDir) {
|
|
481
|
-
return expandTilde(explicitDir);
|
|
482
|
-
}
|
|
483
|
-
if (process.env.GEMINI_CONFIG_DIR) {
|
|
484
|
-
return expandTilde(process.env.GEMINI_CONFIG_DIR);
|
|
485
|
-
}
|
|
486
|
-
return path.join(os.homedir(), '.gemini');
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if (runtime === 'codex') {
|
|
490
|
-
// Codex: --config-dir > CODEX_HOME > ~/.codex
|
|
491
|
-
if (explicitDir) {
|
|
492
|
-
return expandTilde(explicitDir);
|
|
493
|
-
}
|
|
494
|
-
if (process.env.CODEX_HOME) {
|
|
495
|
-
return expandTilde(process.env.CODEX_HOME);
|
|
496
|
-
}
|
|
497
|
-
return path.join(os.homedir(), '.codex');
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (runtime === 'copilot') {
|
|
501
|
-
// Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
|
|
502
|
-
if (explicitDir) {
|
|
503
|
-
return expandTilde(explicitDir);
|
|
504
|
-
}
|
|
505
|
-
if (process.env.COPILOT_CONFIG_DIR) {
|
|
506
|
-
return expandTilde(process.env.COPILOT_CONFIG_DIR);
|
|
507
|
-
}
|
|
508
|
-
return path.join(os.homedir(), '.copilot');
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (runtime === 'antigravity') {
|
|
512
|
-
// Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > auto-detected
|
|
513
|
-
// ~/.gemini/{antigravity,antigravity-ide,antigravity-cli}
|
|
514
|
-
if (explicitDir) {
|
|
515
|
-
return expandTilde(explicitDir);
|
|
516
|
-
}
|
|
517
|
-
return resolveAntigravityGlobalDir();
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
if (runtime === 'cursor') {
|
|
521
|
-
// Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor
|
|
522
|
-
if (explicitDir) {
|
|
523
|
-
return expandTilde(explicitDir);
|
|
524
|
-
}
|
|
525
|
-
if (process.env.CURSOR_CONFIG_DIR) {
|
|
526
|
-
return expandTilde(process.env.CURSOR_CONFIG_DIR);
|
|
527
|
-
}
|
|
528
|
-
return path.join(os.homedir(), '.cursor');
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (runtime === 'windsurf') {
|
|
532
|
-
// Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf
|
|
533
|
-
if (explicitDir) {
|
|
534
|
-
return expandTilde(explicitDir);
|
|
535
|
-
}
|
|
536
|
-
if (process.env.WINDSURF_CONFIG_DIR) {
|
|
537
|
-
return expandTilde(process.env.WINDSURF_CONFIG_DIR);
|
|
538
|
-
}
|
|
539
|
-
return path.join(os.homedir(), '.codeium', 'windsurf');
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (runtime === 'augment') {
|
|
543
|
-
// Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
|
|
544
|
-
if (explicitDir) {
|
|
545
|
-
return expandTilde(explicitDir);
|
|
546
|
-
}
|
|
547
|
-
if (process.env.AUGMENT_CONFIG_DIR) {
|
|
548
|
-
return expandTilde(process.env.AUGMENT_CONFIG_DIR);
|
|
549
|
-
}
|
|
550
|
-
return path.join(os.homedir(), '.augment');
|
|
551
|
-
}
|
|
552
|
-
if (runtime === 'trae') {
|
|
553
|
-
// Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae
|
|
554
|
-
if (explicitDir) {
|
|
555
|
-
return expandTilde(explicitDir);
|
|
556
|
-
}
|
|
557
|
-
if (process.env.TRAE_CONFIG_DIR) {
|
|
558
|
-
return expandTilde(process.env.TRAE_CONFIG_DIR);
|
|
559
|
-
}
|
|
560
|
-
return path.join(os.homedir(), '.trae');
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
if (runtime === 'qwen') {
|
|
564
|
-
if (explicitDir) {
|
|
565
|
-
return expandTilde(explicitDir);
|
|
566
|
-
}
|
|
567
|
-
if (process.env.QWEN_CONFIG_DIR) {
|
|
568
|
-
return expandTilde(process.env.QWEN_CONFIG_DIR);
|
|
569
|
-
}
|
|
570
|
-
return path.join(os.homedir(), '.qwen');
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
if (runtime === 'hermes') {
|
|
574
|
-
// Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes
|
|
575
|
-
// Honors HERMES_HOME which Hermes users set for profile mode / Docker
|
|
576
|
-
// deploys (docs: https://hermes-agent.nousresearch.com/docs).
|
|
577
|
-
if (explicitDir) {
|
|
578
|
-
return expandTilde(explicitDir);
|
|
579
|
-
}
|
|
580
|
-
if (process.env.HERMES_HOME) {
|
|
581
|
-
return expandTilde(process.env.HERMES_HOME);
|
|
582
|
-
}
|
|
583
|
-
return path.join(os.homedir(), '.hermes');
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
if (runtime === 'codebuddy') {
|
|
587
|
-
// CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy
|
|
588
|
-
if (explicitDir) {
|
|
589
|
-
return expandTilde(explicitDir);
|
|
590
|
-
}
|
|
591
|
-
if (process.env.CODEBUDDY_CONFIG_DIR) {
|
|
592
|
-
return expandTilde(process.env.CODEBUDDY_CONFIG_DIR);
|
|
593
|
-
}
|
|
594
|
-
return path.join(os.homedir(), '.codebuddy');
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
if (runtime === 'cline') {
|
|
598
|
-
// Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline
|
|
599
|
-
if (explicitDir) {
|
|
600
|
-
return expandTilde(explicitDir);
|
|
601
|
-
}
|
|
602
|
-
if (process.env.CLINE_CONFIG_DIR) {
|
|
603
|
-
return expandTilde(process.env.CLINE_CONFIG_DIR);
|
|
604
|
-
}
|
|
605
|
-
return path.join(os.homedir(), '.cline');
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
|
|
609
|
-
if (explicitDir) {
|
|
610
|
-
return expandTilde(explicitDir);
|
|
611
|
-
}
|
|
612
|
-
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
613
|
-
return expandTilde(process.env.CLAUDE_CONFIG_DIR);
|
|
614
|
-
}
|
|
615
|
-
return path.join(os.homedir(), '.claude');
|
|
616
|
-
}
|
|
617
|
-
|
|
618
514
|
const banner = '\n' +
|
|
619
515
|
cyan + ' ██████╗ ███████╗██████╗\n' +
|
|
620
516
|
' ██╔════╝ ██╔════╝██╔══██╗\n' +
|
|
@@ -683,20 +579,10 @@ if (hasUninstall) {
|
|
|
683
579
|
|
|
684
580
|
// Show help if requested
|
|
685
581
|
if (hasHelp) {
|
|
686
|
-
console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`);
|
|
582
|
+
console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`);
|
|
687
583
|
process.exit(0);
|
|
688
584
|
}
|
|
689
585
|
|
|
690
|
-
/**
|
|
691
|
-
* Expand ~ to home directory (shell doesn't expand in env vars passed to node)
|
|
692
|
-
*/
|
|
693
|
-
function expandTilde(filePath) {
|
|
694
|
-
if (filePath && filePath.startsWith('~/')) {
|
|
695
|
-
return path.join(os.homedir(), filePath.slice(2));
|
|
696
|
-
}
|
|
697
|
-
return filePath;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
586
|
/**
|
|
701
587
|
* Compute the path prefix used for `@file` references in installed command/skill
|
|
702
588
|
* markdown. For global installs into a runtime config dir under $HOME, we
|
|
@@ -991,9 +877,31 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
|
|
|
991
877
|
return { content: updated, changed };
|
|
992
878
|
}
|
|
993
879
|
|
|
994
|
-
|
|
880
|
+
/**
|
|
881
|
+
* Generic reconcile helper: ensure hooks.json contains exactly one managed GSD
|
|
882
|
+
* hook entry for `eventName`, while preserving all user-owned entries.
|
|
883
|
+
*
|
|
884
|
+
* Supports both known hooks.json shapes:
|
|
885
|
+
* 1) { "<EventName>": [...] }
|
|
886
|
+
* 2) { "hooks": { "<EventName>": [...] } }
|
|
887
|
+
*
|
|
888
|
+
* @param {string} targetDir - Codex config dir (e.g. ~/.codex or <project>/.codex).
|
|
889
|
+
* @param {string} eventName - Codex hook event name (e.g. 'SessionStart', 'Stop').
|
|
890
|
+
* @param {{ managedCommand?: string|null, commandWindows?: string|null, matcher?: string|null, timeout?: number|null }} opts
|
|
891
|
+
* managedCommand: POSIX hook command string to register, or null to remove.
|
|
892
|
+
* commandWindows: Windows .cmd shim path to emit as `commandWindows` field
|
|
893
|
+
* (#772). When provided, Codex uses this path on Windows and `managedCommand`
|
|
894
|
+
* on POSIX without needing per-platform config regeneration.
|
|
895
|
+
* matcher: optional Codex MatcherGroup pattern (e.g. 'Bash|Edit|Write').
|
|
896
|
+
* timeout: optional timeout in seconds.
|
|
897
|
+
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
898
|
+
*/
|
|
899
|
+
function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
995
900
|
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
996
901
|
const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
|
|
902
|
+
const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
|
|
903
|
+
const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
|
|
904
|
+
const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
|
|
997
905
|
let parsed = {};
|
|
998
906
|
let currentContent = null;
|
|
999
907
|
if (fs.existsSync(hooksJsonPath)) {
|
|
@@ -1012,22 +920,22 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1012
920
|
const usesNestedHooksObject =
|
|
1013
921
|
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
|
|
1014
922
|
const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
|
|
1015
|
-
const
|
|
923
|
+
const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
|
|
1016
924
|
|
|
1017
925
|
let removedLegacy = false;
|
|
1018
|
-
const
|
|
1019
|
-
for (const entry of
|
|
926
|
+
const sanitizedEntries = [];
|
|
927
|
+
for (const entry of eventEntries) {
|
|
1020
928
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
1021
929
|
const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
1022
930
|
if (originalHooks.length === 0) {
|
|
1023
|
-
|
|
931
|
+
sanitizedEntries.push(entry);
|
|
1024
932
|
continue;
|
|
1025
933
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
934
|
+
const keptHooks = originalHooks.filter((hook) => {
|
|
935
|
+
const cmd = hook && typeof hook === 'object' ? hook.command : null;
|
|
936
|
+
const managed = isManagedHookCommand(cmd, {
|
|
937
|
+
surface: 'codex-hooks-json',
|
|
938
|
+
includeLegacyAliases: true,
|
|
1031
939
|
configDir: targetDir,
|
|
1032
940
|
});
|
|
1033
941
|
if (managed) removedLegacy = true;
|
|
@@ -1035,24 +943,26 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1035
943
|
});
|
|
1036
944
|
if (keptHooks.length === 0) continue;
|
|
1037
945
|
const nextEntry = { ...entry, hooks: keptHooks };
|
|
1038
|
-
|
|
946
|
+
sanitizedEntries.push(nextEntry);
|
|
1039
947
|
}
|
|
1040
948
|
|
|
1041
949
|
if (managedCommand) {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
950
|
+
const hookEntry = { type: 'command', command: managedCommand };
|
|
951
|
+
// #772: emit commandWindows so Codex picks the .cmd shim on Windows and
|
|
952
|
+
// the POSIX command on other platforms — without requiring per-OS config
|
|
953
|
+
// regeneration. Sourced from HookHandlerConfig.command_windows field in
|
|
954
|
+
// codex-rs/config/src/hook_config.rs (alias: commandWindows).
|
|
955
|
+
if (commandWindows) hookEntry.commandWindows = commandWindows;
|
|
956
|
+
if (timeout !== undefined) hookEntry.timeout = timeout;
|
|
957
|
+
const newEntry = { hooks: [hookEntry] };
|
|
958
|
+
if (matcher !== undefined) newEntry.matcher = matcher;
|
|
959
|
+
sanitizedEntries.push(newEntry);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (sanitizedEntries.length > 0) {
|
|
963
|
+
hookTable[eventName] = sanitizedEntries;
|
|
1054
964
|
} else {
|
|
1055
|
-
delete hookTable
|
|
965
|
+
delete hookTable[eventName];
|
|
1056
966
|
}
|
|
1057
967
|
if (usesNestedHooksObject) parsed.hooks = hookTable;
|
|
1058
968
|
|
|
@@ -1066,6 +976,18 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1066
976
|
return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
|
|
1067
977
|
}
|
|
1068
978
|
|
|
979
|
+
/**
|
|
980
|
+
* Reconcile the GSD-managed SessionStart hook entry in hooks.json.
|
|
981
|
+
* Delegates to the generic reconcileCodexHooksJsonEvent helper.
|
|
982
|
+
*
|
|
983
|
+
* @param {string} targetDir
|
|
984
|
+
* @param {{ managedCommand?: string|null, commandWindows?: string|null }} opts
|
|
985
|
+
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
986
|
+
*/
|
|
987
|
+
function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
988
|
+
return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts);
|
|
989
|
+
}
|
|
990
|
+
|
|
1069
991
|
/**
|
|
1070
992
|
* Build a typed IR for the Codex hook .cmd shim used on Windows (#3426).
|
|
1071
993
|
*
|
|
@@ -1147,8 +1069,14 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
|
|
|
1147
1069
|
* 2) { "hooks": { "SessionStart": [...] } }
|
|
1148
1070
|
*
|
|
1149
1071
|
* On Windows, writes a .cmd shim alongside the .js hook file and uses the
|
|
1150
|
-
* .cmd path as the hook command to avoid the `bash.exe: cannot execute
|
|
1151
|
-
* file` failure (#3426).
|
|
1072
|
+
* .cmd shim path as the hook command to avoid the `bash.exe: cannot execute
|
|
1073
|
+
* binary file` failure (#3426).
|
|
1074
|
+
*
|
|
1075
|
+
* #772: also emits `commandWindows` in the hook entry so that a
|
|
1076
|
+
* cross-platform hooks.json works on both POSIX and Windows without
|
|
1077
|
+
* requiring per-OS regeneration. Codex dispatches `commandWindows` on
|
|
1078
|
+
* Windows and `command` on other platforms (HookHandlerConfig in
|
|
1079
|
+
* codex-rs/config/src/hook_config.rs).
|
|
1152
1080
|
*
|
|
1153
1081
|
* @param {string} targetDir
|
|
1154
1082
|
* @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
|
|
@@ -1160,7 +1088,18 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1160
1088
|
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
1161
1089
|
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1162
1090
|
|
|
1163
|
-
|
|
1091
|
+
// Normalize backslashes to forward slashes so isManagedHookCommand can
|
|
1092
|
+
// match stored commands against configDir on Windows CI runners where
|
|
1093
|
+
// path.resolve returns backslash paths but the stored command may use
|
|
1094
|
+
// forward slashes (or vice versa). Forward-slash paths are always valid on
|
|
1095
|
+
// Windows for both Node.js and Codex, so this normalization is safe for all
|
|
1096
|
+
// platforms. (#772 — same fix applied to ensureCodexHooksJsonEvent.)
|
|
1097
|
+
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
|
1098
|
+
|
|
1099
|
+
// #772: compute the Windows .cmd shim path cross-platform so that
|
|
1100
|
+
// `commandWindows` can be emitted in hooks.json regardless of the host OS.
|
|
1101
|
+
// The .cmd path is always the .js script path with extension replaced.
|
|
1102
|
+
const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
|
|
1164
1103
|
|
|
1165
1104
|
let managedCommand;
|
|
1166
1105
|
if (platform === 'win32') {
|
|
@@ -1197,7 +1136,96 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1197
1136
|
}
|
|
1198
1137
|
|
|
1199
1138
|
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1200
|
-
|
|
1139
|
+
|
|
1140
|
+
// #772: emit commandWindows — the .cmd shim path — but ONLY on Windows where
|
|
1141
|
+
// the shim was actually written. On POSIX, commandWindows is omitted to avoid
|
|
1142
|
+
// pointing Windows Codex at a non-existent .cmd file (the shim is only present
|
|
1143
|
+
// when install() ran natively on Windows and wrote it via buildCodexHookWindowsShimIR).
|
|
1144
|
+
const commandWindows = platform === 'win32'
|
|
1145
|
+
? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
|
|
1146
|
+
: undefined;
|
|
1147
|
+
|
|
1148
|
+
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Ensure hooks.json contains exactly one managed GSD hook entry for the given
|
|
1153
|
+
* Codex event, wired to gsd-context-monitor.js. Preserves user-owned entries.
|
|
1154
|
+
*
|
|
1155
|
+
* Used for the new Codex events added in #772:
|
|
1156
|
+
* SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open
|
|
1157
|
+
* Stop — post-session context headroom tracking
|
|
1158
|
+
* PostToolUse — mirror the Claude Code PostToolUse context monitor
|
|
1159
|
+
*
|
|
1160
|
+
* All three events are routed through gsd-context-monitor.js — the same hook
|
|
1161
|
+
* used for PostToolUse in the Claude Code baseline — so context-headroom
|
|
1162
|
+
* warnings surface at these key Codex session lifecycle moments.
|
|
1163
|
+
*
|
|
1164
|
+
* On Windows (#3426): writes a gsd-context-monitor.cmd shim alongside the .js
|
|
1165
|
+
* file and uses the .cmd path as the hook command — exactly the same fix as
|
|
1166
|
+
* SessionStart uses for gsd-check-update — to avoid the bash.exe POSIX-exec
|
|
1167
|
+
* failure when Codex's hook dispatcher tries to run node.exe through Git Bash.
|
|
1168
|
+
*
|
|
1169
|
+
* @param {string} targetDir
|
|
1170
|
+
* @param {string} eventName - One of 'SubagentStart', 'Stop', 'PostToolUse'.
|
|
1171
|
+
* @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
|
|
1172
|
+
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
1173
|
+
*/
|
|
1174
|
+
function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
1175
|
+
const platform = opts.platform || process.platform;
|
|
1176
|
+
const absoluteRunner = opts.absoluteRunner || null;
|
|
1177
|
+
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
1178
|
+
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1179
|
+
|
|
1180
|
+
// Normalize backslashes to forward slashes so that isManagedHookCommand can
|
|
1181
|
+
// match the stored command against configDir on Windows. path.resolve on
|
|
1182
|
+
// Windows returns backslash paths, but when platform is not 'win32'
|
|
1183
|
+
// (e.g. platform: 'linux' in a test running on a Windows CI runner),
|
|
1184
|
+
// projectManagedHookCommand does not normalize them — producing a mismatch
|
|
1185
|
+
// between the stored command and the configDir-based hook-dir prefix used
|
|
1186
|
+
// for deduplication. Forward-slash paths are always valid on Windows (Node.js
|
|
1187
|
+
// and Codex both accept them), so normalizing here is safe for all platforms.
|
|
1188
|
+
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
|
|
1189
|
+
|
|
1190
|
+
let managedCommand;
|
|
1191
|
+
if (platform === 'win32') {
|
|
1192
|
+
// #3426 fix pattern: on Windows, write a .cmd shim and use its path as the
|
|
1193
|
+
// hook command. The same bash.exe POSIX-exec failure that affects
|
|
1194
|
+
// gsd-check-update.js also affects gsd-context-monitor.js.
|
|
1195
|
+
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
|
|
1196
|
+
if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1197
|
+
try {
|
|
1198
|
+
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
|
|
1199
|
+
} catch (shimWriteErr) {
|
|
1200
|
+
const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
|
|
1201
|
+
console.warn(
|
|
1202
|
+
` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
|
|
1203
|
+
`Fix the write error (permissions? disk full?) and re-run the installer.`,
|
|
1204
|
+
);
|
|
1205
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1206
|
+
}
|
|
1207
|
+
managedCommand = shimIR.hookCommand;
|
|
1208
|
+
} else {
|
|
1209
|
+
managedCommand = projectManagedHookCommand({
|
|
1210
|
+
absoluteRunner,
|
|
1211
|
+
scriptPath,
|
|
1212
|
+
runtime: 'codex',
|
|
1213
|
+
platform,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1218
|
+
return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Remove a GSD-managed event entry from hooks.json. Called during uninstall.
|
|
1223
|
+
*
|
|
1224
|
+
* @param {string} targetDir
|
|
1225
|
+
* @param {string} eventName
|
|
1226
|
+
*/
|
|
1227
|
+
function removeCodexHooksJsonEvent(targetDir, eventName) {
|
|
1228
|
+
return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null });
|
|
1201
1229
|
}
|
|
1202
1230
|
|
|
1203
1231
|
function removeCodexHooksJsonSessionStart(targetDir) {
|
|
@@ -1769,11 +1797,11 @@ function getCommitAttribution(runtime) {
|
|
|
1769
1797
|
const resolveConfigPath = runtime === 'opencode'
|
|
1770
1798
|
? resolveOpencodeConfigPath
|
|
1771
1799
|
: resolveKiloConfigPath;
|
|
1772
|
-
const config = readSettings(resolveConfigPath(
|
|
1800
|
+
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
|
|
1773
1801
|
result = (config && config.disable_ai_attribution === true) ? null : undefined;
|
|
1774
1802
|
} else if (runtime === 'gemini') {
|
|
1775
1803
|
// Gemini: check gemini settings.json for attribution config
|
|
1776
|
-
const settings = readSettings(path.join(
|
|
1804
|
+
const settings = readSettings(path.join(getGlobalConfigDir('gemini', explicitConfigDir), 'settings.json'));
|
|
1777
1805
|
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
|
|
1778
1806
|
result = undefined;
|
|
1779
1807
|
} else if (settings.attribution.commit === '') {
|
|
@@ -1783,7 +1811,7 @@ function getCommitAttribution(runtime) {
|
|
|
1783
1811
|
}
|
|
1784
1812
|
} else if (runtime === 'claude') {
|
|
1785
1813
|
// Claude Code
|
|
1786
|
-
const settings = readSettings(path.join(
|
|
1814
|
+
const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
|
|
1787
1815
|
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
|
|
1788
1816
|
result = undefined;
|
|
1789
1817
|
} else if (settings.attribution.commit === '') {
|
|
@@ -1997,6 +2025,8 @@ function convertCopilotToolName(claudeTool) {
|
|
|
1997
2025
|
if (claudeToCopilotTools[claudeTool]) {
|
|
1998
2026
|
return claudeToCopilotTools[claudeTool];
|
|
1999
2027
|
}
|
|
2028
|
+
// mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl;
|
|
2029
|
+
// add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up)
|
|
2000
2030
|
// Default: lowercase
|
|
2001
2031
|
return claudeTool.toLowerCase();
|
|
2002
2032
|
}
|
|
@@ -2091,6 +2121,40 @@ function skillFrontmatterName(skillDirName) {
|
|
|
2091
2121
|
return skillDirName;
|
|
2092
2122
|
}
|
|
2093
2123
|
|
|
2124
|
+
/**
|
|
2125
|
+
* Qwen Code skills accept an optional numeric `priority` frontmatter field.
|
|
2126
|
+
* Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
|
|
2127
|
+
* #778): HIGHER values sort EARLIER in the `/skills` TUI listing (omitted ≈ 0;
|
|
2128
|
+
* negatives sort below unset). It affects ONLY the `/skills` list order —
|
|
2129
|
+
* slash-command completion and the `/help` view stay alphabetical.
|
|
2130
|
+
*
|
|
2131
|
+
* We assign descending priorities to GSD's main-loop commands so the most-used
|
|
2132
|
+
* workflow skills surface first; utility skills are deliberately left unset
|
|
2133
|
+
* (default 0) and sort below.
|
|
2134
|
+
*
|
|
2135
|
+
* NOTE: the #778 issue body proposed the INVERSE numbering (plan-phase: 10,
|
|
2136
|
+
* utilities: 90+). The verified spec shows that would BURY the core loop below
|
|
2137
|
+
* utilities, so we implement the spec-correct direction (core = high) instead.
|
|
2138
|
+
* Keyed by command stem (skill dir is `gsd-<stem>`).
|
|
2139
|
+
*/
|
|
2140
|
+
const QWEN_SKILL_PRIORITY = Object.freeze({
|
|
2141
|
+
'new-project': 100,
|
|
2142
|
+
'discuss-phase': 95,
|
|
2143
|
+
'plan-phase': 90,
|
|
2144
|
+
'execute-phase': 85,
|
|
2145
|
+
progress: 80,
|
|
2146
|
+
'verify-work': 75,
|
|
2147
|
+
phase: 70,
|
|
2148
|
+
review: 65,
|
|
2149
|
+
ship: 60,
|
|
2150
|
+
config: 55,
|
|
2151
|
+
surface: 50,
|
|
2152
|
+
'resume-work': 45,
|
|
2153
|
+
'pause-work': 40,
|
|
2154
|
+
help: 35,
|
|
2155
|
+
update: 30,
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2094
2158
|
/**
|
|
2095
2159
|
* Convert a Claude command (.md) to a Claude skill (SKILL.md).
|
|
2096
2160
|
* Claude Code is the native format, so minimal conversion needed —
|
|
@@ -2113,6 +2177,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
|
|
2113
2177
|
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
|
2114
2178
|
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
|
|
2115
2179
|
const agent = extractFrontmatterField(frontmatter, 'agent');
|
|
2180
|
+
// #769: preserve context: and effort: from source command files so they
|
|
2181
|
+
// are emitted into the installed SKILL.md frontmatter unchanged.
|
|
2182
|
+
const context = extractFrontmatterField(frontmatter, 'context');
|
|
2183
|
+
const effort = extractFrontmatterField(frontmatter, 'effort');
|
|
2116
2184
|
|
|
2117
2185
|
// Preserve allowed-tools as YAML multiline list (Claude native format)
|
|
2118
2186
|
const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
|
|
@@ -2130,8 +2198,26 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
|
|
2130
2198
|
// Track GSD's package version so Hermes' skill_view() reports a stable
|
|
2131
2199
|
// identifier per install.
|
|
2132
2200
|
if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
|
|
2201
|
+
// #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
|
|
2202
|
+
// so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
|
|
2203
|
+
// we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
|
|
2204
|
+
if (runtime === 'qwen') {
|
|
2205
|
+
const stem = typeof skillName === 'string' && skillName.startsWith('gsd-')
|
|
2206
|
+
? skillName.slice(4)
|
|
2207
|
+
: skillName;
|
|
2208
|
+
const priority = Object.prototype.hasOwnProperty.call(QWEN_SKILL_PRIORITY, stem)
|
|
2209
|
+
? QWEN_SKILL_PRIORITY[stem]
|
|
2210
|
+
: undefined;
|
|
2211
|
+
if (typeof priority === 'number') fm += `priority: ${priority}\n`;
|
|
2212
|
+
}
|
|
2133
2213
|
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
|
|
2134
2214
|
if (agent) fm += `agent: ${agent}\n`;
|
|
2215
|
+
// #769: emit context: and effort: when present so the runtime can honour
|
|
2216
|
+
// them natively (context: fork = isolated subagent window; effort: =
|
|
2217
|
+
// token-budget tier). Fields are Claude-specific; unknown frontmatter
|
|
2218
|
+
// fields are silently ignored by other runtimes (backward-compatible).
|
|
2219
|
+
if (context) fm += `context: ${context}\n`;
|
|
2220
|
+
if (effort) fm += `effort: ${effort}\n`;
|
|
2135
2221
|
if (toolsBlock) fm += toolsBlock;
|
|
2136
2222
|
fm += '---';
|
|
2137
2223
|
|
|
@@ -2384,6 +2470,29 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
|
|
|
2384
2470
|
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
|
|
2385
2471
|
}
|
|
2386
2472
|
|
|
2473
|
+
/**
|
|
2474
|
+
* Convert a Claude Code command to a Cursor 1.6 slash command (#785).
|
|
2475
|
+
*
|
|
2476
|
+
* Cursor slash commands live in `.cursor/commands/<name>.md` and are
|
|
2477
|
+
* plain markdown — no YAML frontmatter, no adapter header. The filename
|
|
2478
|
+
* becomes the command name (e.g. `gsd-help.md` → `/gsd-help`).
|
|
2479
|
+
*
|
|
2480
|
+
* Applies the same `convertClaudeToCursorMarkdown` transforms as the skill
|
|
2481
|
+
* converter (tool renames, brand substitution, slash-command normalisation),
|
|
2482
|
+
* then strips the YAML frontmatter block so only the prose body remains.
|
|
2483
|
+
*
|
|
2484
|
+
* @param {string} content raw Claude Code command markdown (may have frontmatter)
|
|
2485
|
+
* @param {string} _commandName the target command name (unused; present for
|
|
2486
|
+
* API symmetry with other converters so the runtime-artifact-layout stage
|
|
2487
|
+
* function can call it uniformly)
|
|
2488
|
+
* @returns {string} plain markdown body, no frontmatter
|
|
2489
|
+
*/
|
|
2490
|
+
function convertClaudeCommandToCursorCommand(content, _commandName) {
|
|
2491
|
+
const converted = convertClaudeToCursorMarkdown(content);
|
|
2492
|
+
const { body } = extractFrontmatterAndBody(converted);
|
|
2493
|
+
return body.trimStart();
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2387
2496
|
/**
|
|
2388
2497
|
* Convert Claude Code agent markdown to Cursor agent format.
|
|
2389
2498
|
* Strips frontmatter fields Cursor doesn't support (color, skills),
|
|
@@ -2747,7 +2856,47 @@ function convertClaudeCommandToCodebuddySkill(content, skillName) {
|
|
|
2747
2856
|
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
|
|
2748
2857
|
// #2876: quote so YAML flow indicators (`[BETA] …`) don't break
|
|
2749
2858
|
// CodeBuddy's frontmatter parser.
|
|
2750
|
-
|
|
2859
|
+
//
|
|
2860
|
+
// #789: mark user-invocable:false so the skill is NOT shown in CodeBuddy's
|
|
2861
|
+
// '/' menu (it defaults to true). The commands/ surface (#789) is the sole
|
|
2862
|
+
// '/' entry point; skills remain model-invocable background knowledge,
|
|
2863
|
+
// avoiding a duplicated /gsd-* entry per workflow.
|
|
2864
|
+
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n${body}`;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
/**
|
|
2868
|
+
* Convert a Claude Code slash-command (.md) to a CodeBuddy slash-command (.md).
|
|
2869
|
+
*
|
|
2870
|
+
* CodeBuddy reads user-level slash commands from ~/.codebuddy/commands/<name>.md
|
|
2871
|
+
* (https://www.codebuddy.ai/docs/cli/slash-commands). The filename determines the
|
|
2872
|
+
* command name (gsd-help.md → /gsd-help), so the Claude-specific `name: gsd:<x>`
|
|
2873
|
+
* frontmatter field is dropped. CodeBuddy command frontmatter supports
|
|
2874
|
+
* `description` and `argument-hint`; both are preserved when present. The body is
|
|
2875
|
+
* brand/path-converted via convertClaudeToCodebuddyMarkdown.
|
|
2876
|
+
*
|
|
2877
|
+
* @param {string} content raw Claude command markdown
|
|
2878
|
+
* @param {string} commandName installed command name (e.g. 'gsd-help')
|
|
2879
|
+
* @returns {string}
|
|
2880
|
+
*/
|
|
2881
|
+
function convertClaudeCommandToCodebuddyCommand(content, commandName) {
|
|
2882
|
+
const converted = convertClaudeToCodebuddyMarkdown(content);
|
|
2883
|
+
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
|
2884
|
+
let description = `Run GSD workflow ${commandName}.`;
|
|
2885
|
+
let argumentHint = '';
|
|
2886
|
+
if (frontmatter) {
|
|
2887
|
+
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
|
|
2888
|
+
if (maybeDescription) description = maybeDescription;
|
|
2889
|
+
const maybeArgHint = extractFrontmatterField(frontmatter, 'argument-hint');
|
|
2890
|
+
if (maybeArgHint) argumentHint = maybeArgHint;
|
|
2891
|
+
}
|
|
2892
|
+
description = toSingleLine(description);
|
|
2893
|
+
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
|
|
2894
|
+
// #2876: quote values so YAML flow indicators (`[BETA] …`, `[name]`) don't
|
|
2895
|
+
// break CodeBuddy's frontmatter parser.
|
|
2896
|
+
const lines = ['---', `description: ${yamlQuote(shortDescription)}`];
|
|
2897
|
+
if (argumentHint) lines.push(`argument-hint: ${yamlQuote(toSingleLine(argumentHint))}`);
|
|
2898
|
+
lines.push('---', body.trimStart());
|
|
2899
|
+
return lines.join('\n');
|
|
2751
2900
|
}
|
|
2752
2901
|
|
|
2753
2902
|
function convertClaudeAgentToCodebuddyAgent(content) {
|
|
@@ -2773,9 +2922,15 @@ function convertClaudeToCliineMarkdown(content) {
|
|
|
2773
2922
|
converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules');
|
|
2774
2923
|
converted = converted.replace(/`CLAUDE\.md`/g, '`.clinerules`');
|
|
2775
2924
|
converted = converted.replace(/\bCLAUDE\.md\b/g, '.clinerules');
|
|
2925
|
+
// Slash forms first (most specific — superset of bare forms)
|
|
2776
2926
|
converted = converted.replace(/\.claude\/skills\//g, '.cline/skills/');
|
|
2777
2927
|
converted = converted.replace(/\.\/\.claude\//g, './.cline/');
|
|
2778
2928
|
converted = converted.replace(/\.claude\//g, '.cline/');
|
|
2929
|
+
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite
|
|
2930
|
+
converted = converted.replace(/~\/\.claude\b/g, '~/.cline');
|
|
2931
|
+
converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.cline');
|
|
2932
|
+
// Environment variable name rewrite
|
|
2933
|
+
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'CLINE_CONFIG_DIR');
|
|
2779
2934
|
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
|
|
2780
2935
|
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
|
|
2781
2936
|
converted = converted.replace(/\bClaude Code\b/g, 'Cline');
|
|
@@ -2792,17 +2947,62 @@ function convertClaudeAgentToClineAgent(content) {
|
|
|
2792
2947
|
return `${cleanFrontmatter}\n${body}`;
|
|
2793
2948
|
}
|
|
2794
2949
|
|
|
2950
|
+
/**
|
|
2951
|
+
* Convert a Claude command (.md) to a Cline skill (SKILL.md).
|
|
2952
|
+
* Emits ONLY name + description frontmatter per the Cline skills spec
|
|
2953
|
+
* (https://docs.cline.bot/customization/skills) — no allowed-tools,
|
|
2954
|
+
* argument-hint, agent, or other Claude-specific fields.
|
|
2955
|
+
* Body is hyphen-normalised then converted via convertClaudeToCliineMarkdown
|
|
2956
|
+
* (.claude/→.cline/, "Claude Code"→"Cline", etc.).
|
|
2957
|
+
* Cline uses Claude-Code-compatible tool names, so no adapter header is needed.
|
|
2958
|
+
* Targets ~/.cline/skills/<name>/SKILL.md for Cline >= v3.48.0.
|
|
2959
|
+
*/
|
|
2960
|
+
function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cmdNames = null) {
|
|
2961
|
+
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
|
2962
|
+
if (!frontmatter) return content;
|
|
2963
|
+
|
|
2964
|
+
// Hyphen-normalise /gsd:<cmd> → gsd-<cmd> references in the body, then
|
|
2965
|
+
// apply Cline-specific markdown rewrites (.claude/→.cline/, etc.).
|
|
2966
|
+
const names = cmdNames || readGsdCommandNames();
|
|
2967
|
+
const normalizedBody = transformContentToHyphen(body, names);
|
|
2968
|
+
const clineBody = convertClaudeToCliineMarkdown(normalizedBody);
|
|
2969
|
+
|
|
2970
|
+
// Extract description; fall back to a generic string if absent.
|
|
2971
|
+
let description = extractFrontmatterField(frontmatter, 'description');
|
|
2972
|
+
if (!description) description = `Run GSD workflow ${skillName}.`;
|
|
2973
|
+
description = toSingleLine(description);
|
|
2974
|
+
// Cline documented max is 1024 code points (not UTF-16 code units).
|
|
2975
|
+
// Use Array.from to iterate by code point so that multibyte characters
|
|
2976
|
+
// (e.g. emoji, astral-plane chars) are never split, which would produce
|
|
2977
|
+
// lone surrogates and corrupt the YAML output.
|
|
2978
|
+
const cp = Array.from(description);
|
|
2979
|
+
const shortDescription = cp.length > 1024
|
|
2980
|
+
? cp.slice(0, 1021).join('') + '...'
|
|
2981
|
+
: description;
|
|
2982
|
+
|
|
2983
|
+
const fm = `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---`;
|
|
2984
|
+
return `${fm}\n${clineBody}`;
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2795
2987
|
// ── End Cline converters ─────────────────────────────────────────────────────
|
|
2796
2988
|
|
|
2797
2989
|
function convertSlashCommandsToCodexSkillMentions(content) {
|
|
2798
|
-
//
|
|
2990
|
+
// Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
|
|
2799
2991
|
let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
|
|
2800
2992
|
return `$gsd-${String(commandName).toLowerCase()}`;
|
|
2801
2993
|
});
|
|
2802
2994
|
// Convert hyphen-style command references (workflow output) to Codex $ prefix.
|
|
2803
|
-
//
|
|
2804
|
-
//
|
|
2805
|
-
|
|
2995
|
+
// A real /gsd-<cmd> MENTION is defined positively by two boundaries, so any
|
|
2996
|
+
// in-path occurrence is excluded by construction (no denylist of preceding
|
|
2997
|
+
// chars to maintain — see #712, supersedes the #637/#704 lookbehind treadmill):
|
|
2998
|
+
// 1. Left boundary: opens at start-of-string, whitespace, or an inline-prose
|
|
2999
|
+
// delimiter (backtick/quote/paren/bracket) — e.g. `/gsd-execute-phase`.
|
|
3000
|
+
// 2. Right boundary: the command token is NOT followed by a path separator
|
|
3001
|
+
// `/` (a path continues: `/gsd-core/bin/...`; a command does not). The
|
|
3002
|
+
// `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command.
|
|
3003
|
+
// This converts backtick-wrapped MENTIONS (`/gsd-foo`) while leaving backtick-
|
|
3004
|
+
// wrapped PATHS (`/gsd-core/workflows/update.md`) untouched (#712).
|
|
3005
|
+
converted = converted.replace(/(?<=^|[\s`"'([])\/gsd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => {
|
|
2806
3006
|
return `$gsd-${String(commandName).toLowerCase()}`;
|
|
2807
3007
|
});
|
|
2808
3008
|
return converted;
|
|
@@ -3003,6 +3203,20 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
|
|
|
3003
3203
|
const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
|
|
3004
3204
|
lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
|
|
3005
3205
|
|
|
3206
|
+
// #774 — Emit service_tier and model_verbosity for light-tier agents.
|
|
3207
|
+
// Light-tier agents (routingTier: "light" in model-catalog.json) are haiku-equivalent
|
|
3208
|
+
// and benefit from Codex's "flex" service tier (lower cost, background processing)
|
|
3209
|
+
// and "low" verbosity (reduced token output). Both fields are validated against the
|
|
3210
|
+
// Codex ConfigProfile schema (codex-rs/config/src/profile_toml.rs):
|
|
3211
|
+
// service_tier: Option<String> — "flex" | "fast" (legacy)
|
|
3212
|
+
// model_verbosity: Option<Verbosity> — "low" | "medium" | "high"
|
|
3213
|
+
const { AGENT_DEFAULT_TIERS: _agentTiers } = _getGsdEffortCatalog();
|
|
3214
|
+
const _agentRoutingTier = _agentTiers?.[resolvedName] || _agentTiers?.[agentName];
|
|
3215
|
+
if (_agentRoutingTier === 'light') {
|
|
3216
|
+
lines.push(`service_tier = "flex"`);
|
|
3217
|
+
lines.push(`model_verbosity = "low"`);
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3006
3220
|
// Agent prompts contain raw backslashes in regexes and shell snippets.
|
|
3007
3221
|
// TOML literal multiline strings preserve them without escape parsing.
|
|
3008
3222
|
lines.push(`developer_instructions = '''`);
|
|
@@ -3012,6 +3226,115 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
|
|
|
3012
3226
|
return lines.join('\n') + '\n';
|
|
3013
3227
|
}
|
|
3014
3228
|
|
|
3229
|
+
/**
|
|
3230
|
+
* Generate the agents/openai.yaml TUI chip metadata content for a Codex skill.
|
|
3231
|
+
*
|
|
3232
|
+
* This file is written alongside SKILL.md as <skill-dir>/agents/openai.yaml.
|
|
3233
|
+
* Codex loads it as a SkillMetadataFile (codex-rs/core-skills/src/loader.rs),
|
|
3234
|
+
* making the skill discoverable in the /skills TUI popup with a display name
|
|
3235
|
+
* and short description. If the file is absent, Codex silently skips it (fails open).
|
|
3236
|
+
*
|
|
3237
|
+
* Schema (interface section):
|
|
3238
|
+
* display_name: short human-readable skill name (strip gsd- prefix)
|
|
3239
|
+
* short_description: 1-2 sentence description for TUI chip, ≤180 chars
|
|
3240
|
+
*
|
|
3241
|
+
* @param {string} skillName - Full skill name e.g. "gsd-plan-phase"
|
|
3242
|
+
* @param {string} shortDescription - Description text (already truncated by caller)
|
|
3243
|
+
* @returns {string} YAML content for agents/openai.yaml
|
|
3244
|
+
*/
|
|
3245
|
+
function generateCodexSkillMetadataYaml(skillName, shortDescription) {
|
|
3246
|
+
// Display name: strip "gsd-" prefix and convert hyphens to spaces for readability.
|
|
3247
|
+
const displayName = skillName.replace(/^gsd-/, '').replace(/-/g, ' ');
|
|
3248
|
+
// yamlQuote (= JSON.stringify) handles all YAML-unsafe chars: backslashes,
|
|
3249
|
+
// quotes, newlines, control characters, and Unicode escapes.
|
|
3250
|
+
return [
|
|
3251
|
+
'interface:',
|
|
3252
|
+
` display_name: ${yamlQuote(displayName)}`,
|
|
3253
|
+
` short_description: ${yamlQuote(shortDescription)}`,
|
|
3254
|
+
'',
|
|
3255
|
+
].join('\n');
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
/**
|
|
3259
|
+
* Write agents/openai.yaml TUI chip metadata for each gsd-* skill directory.
|
|
3260
|
+
*
|
|
3261
|
+
* Called after layout-driven skill install for Codex. Iterates every gsd-*
|
|
3262
|
+
* skill directory in skillsDir, reads the SKILL.md frontmatter to extract the
|
|
3263
|
+
* short-description already emitted by convertClaudeCommandToCodexSkill, then
|
|
3264
|
+
* writes <skill-dir>/agents/openai.yaml using generateCodexSkillMetadataYaml.
|
|
3265
|
+
*
|
|
3266
|
+
* Fails open: individual skill directories that cannot be processed are silently
|
|
3267
|
+
* skipped so a single malformed SKILL.md cannot block the whole install.
|
|
3268
|
+
*
|
|
3269
|
+
* User-owned skill directories (e.g. gsd-dev-preferences) are explicitly
|
|
3270
|
+
* skipped so existing user-authored agents/openai.yaml files are never
|
|
3271
|
+
* overwritten. These dirs are listed in the same USER_OWNED_SKILL_DIRS
|
|
3272
|
+
* constant used by installOpencodeFamilySkills.
|
|
3273
|
+
*
|
|
3274
|
+
* The YAML-quoted description value is unescaped before embedding so that
|
|
3275
|
+
* YAML escape sequences (e.g. \" in a double-quoted scalar) become the
|
|
3276
|
+
* literal characters they represent rather than being double-escaped in the
|
|
3277
|
+
* output.
|
|
3278
|
+
*
|
|
3279
|
+
* @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills)
|
|
3280
|
+
*/
|
|
3281
|
+
function writeCodexSkillMetadataFiles(skillsDir) {
|
|
3282
|
+
if (!fs.existsSync(skillsDir)) return;
|
|
3283
|
+
// Mirror the user-owned list from installOpencodeFamilySkills (#2973).
|
|
3284
|
+
// We MUST skip these dirs — their contents are user-generated and must
|
|
3285
|
+
// never be overwritten by GSD's install path.
|
|
3286
|
+
const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
|
|
3287
|
+
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
3288
|
+
if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
|
|
3289
|
+
if (_userOwnedSkillDirs.has(entry.name)) continue; // preserve user content
|
|
3290
|
+
const skillDir = path.join(skillsDir, entry.name);
|
|
3291
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
3292
|
+
try {
|
|
3293
|
+
const content = fs.readFileSync(skillMdPath, 'utf8');
|
|
3294
|
+
const { frontmatter } = extractFrontmatterAndBody(content);
|
|
3295
|
+
// Prefer the short-description field emitted by convertClaudeCommandToCodexSkill;
|
|
3296
|
+
// fall back to description, then a synthetic label from the skill name.
|
|
3297
|
+
let shortDesc = '';
|
|
3298
|
+
if (frontmatter) {
|
|
3299
|
+
// SKILL.md uses YAML frontmatter with a nested metadata.short-description key.
|
|
3300
|
+
// extractFrontmatterField handles only top-level keys; parse the metadata block
|
|
3301
|
+
// by looking for " short-description:" directly.
|
|
3302
|
+
const metaMatch = frontmatter.match(/^[ \t]*metadata\s*:\s*\n((?:[ \t]+.*\n?)*)/m);
|
|
3303
|
+
if (metaMatch) {
|
|
3304
|
+
const metaBlock = metaMatch[1];
|
|
3305
|
+
const sdMatch = metaBlock.match(/^[ \t]+short-description\s*:\s*(.+)$/m);
|
|
3306
|
+
if (sdMatch) {
|
|
3307
|
+
// Unescape YAML double-quoted scalar escapes before embedding.
|
|
3308
|
+
// convertClaudeCommandToCodexSkill always emits a double-quoted
|
|
3309
|
+
// value (via yamlQuote) so only double-quote unescaping is needed.
|
|
3310
|
+
let raw = sdMatch[1].trim();
|
|
3311
|
+
if (raw.startsWith('"') && raw.endsWith('"')) {
|
|
3312
|
+
// Strip outer double-quotes and decode \" → " and \\ → \
|
|
3313
|
+
raw = raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
3314
|
+
} else {
|
|
3315
|
+
// Single-quoted or unquoted: strip surrounding quotes/whitespace
|
|
3316
|
+
raw = raw.replace(/^["']|["']$/g, '');
|
|
3317
|
+
}
|
|
3318
|
+
shortDesc = raw;
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
if (!shortDesc) {
|
|
3322
|
+
shortDesc = extractFrontmatterField(frontmatter, 'description') || '';
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
if (!shortDesc) {
|
|
3326
|
+
shortDesc = `Run GSD workflow ${entry.name}.`;
|
|
3327
|
+
}
|
|
3328
|
+
const yamlContent = generateCodexSkillMetadataYaml(entry.name, shortDesc);
|
|
3329
|
+
const agentsSubdir = path.join(skillDir, 'agents');
|
|
3330
|
+
fs.mkdirSync(agentsSubdir, { recursive: true });
|
|
3331
|
+
fs.writeFileSync(path.join(agentsSubdir, 'openai.yaml'), yamlContent);
|
|
3332
|
+
} catch (_err) {
|
|
3333
|
+
// Fail open — missing or unreadable SKILL.md must not block the install.
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3015
3338
|
/**
|
|
3016
3339
|
* Generate the GSD config block for Codex config.toml.
|
|
3017
3340
|
* @param {Array<{name: string, description: string}>} agents
|
|
@@ -5221,6 +5544,506 @@ function stripGsdFromCopilotInstructions(content) {
|
|
|
5221
5544
|
return content;
|
|
5222
5545
|
}
|
|
5223
5546
|
|
|
5547
|
+
// ── Cline directory-form rules + hooks + AGENTS.md (issue #787) ────────────────
|
|
5548
|
+
//
|
|
5549
|
+
// Cline v3.36 added a hooks system and a `.clinerules/` directory form. Because
|
|
5550
|
+
// `.clinerules` cannot be both a file AND a directory, emitting hooks under
|
|
5551
|
+
// `.clinerules/hooks/` requires migrating the rules content into the directory
|
|
5552
|
+
// form (`.clinerules/gsd.md`). Sources adjudicated:
|
|
5553
|
+
// - https://cline.bot/blog/cline-v3-36-hooks
|
|
5554
|
+
// - https://docs.cline.bot/customization/cline-rules
|
|
5555
|
+
|
|
5556
|
+
const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
|
|
5557
|
+
const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
|
|
5558
|
+
|
|
5559
|
+
/**
|
|
5560
|
+
* The GSD instruction body shared by the Cline directory-form rules file and
|
|
5561
|
+
* the cross-tool AGENTS.md block. Self-contained — references only the gsd-core
|
|
5562
|
+
* engine layout, not the (separate) #782 Cline skills directory.
|
|
5563
|
+
*/
|
|
5564
|
+
function buildClineRulesBody() {
|
|
5565
|
+
return [
|
|
5566
|
+
'# GSD Core — Git. Ship. Done.',
|
|
5567
|
+
'',
|
|
5568
|
+
'- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
|
|
5569
|
+
' the user runs a `/gsd-*` command.',
|
|
5570
|
+
'- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
|
|
5571
|
+
'- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
|
|
5572
|
+
'- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
|
|
5573
|
+
'- Do not apply GSD workflows unless the user explicitly asks for them.',
|
|
5574
|
+
'- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
|
|
5575
|
+
' step to the user using Cline\'s ask_user tool after completing it.',
|
|
5576
|
+
].join('\n') + '\n';
|
|
5577
|
+
}
|
|
5578
|
+
|
|
5579
|
+
/** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
|
|
5580
|
+
function buildClineAgentsMdBody() {
|
|
5581
|
+
return buildClineRulesBody();
|
|
5582
|
+
}
|
|
5583
|
+
|
|
5584
|
+
/**
|
|
5585
|
+
* The Cline PreToolUse hook script (issue #787).
|
|
5586
|
+
*
|
|
5587
|
+
* Cline invokes hooks as executable scripts named exactly after the event with
|
|
5588
|
+
* no extension, passing the operation context as JSON on stdin and reading a
|
|
5589
|
+
* JSON decision from stdout ({ cancel, errorMessage, contextModification }).
|
|
5590
|
+
*
|
|
5591
|
+
* This hook is a self-standing planning-artifact guard: it cancels write-class
|
|
5592
|
+
* tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
|
|
5593
|
+
* allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
|
|
5594
|
+
* hook bug can never wedge the user. No dependency on the #782 skills work.
|
|
5595
|
+
*/
|
|
5596
|
+
function buildClinePreToolUseHook() {
|
|
5597
|
+
return `#!/usr/bin/env node
|
|
5598
|
+
'use strict';
|
|
5599
|
+
/* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
|
|
5600
|
+
* Protocol: JSON on stdin -> JSON decision on stdout.
|
|
5601
|
+
* Honored fields: { cancel, errorMessage, contextModification }.
|
|
5602
|
+
* Fails open: any error allows the operation. */
|
|
5603
|
+
let raw = '';
|
|
5604
|
+
process.stdin.setEncoding('utf8');
|
|
5605
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
5606
|
+
process.stdin.on('end', () => {
|
|
5607
|
+
const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
|
|
5608
|
+
let input;
|
|
5609
|
+
try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
|
|
5610
|
+
try {
|
|
5611
|
+
const tool = String(
|
|
5612
|
+
input.toolName || input.tool_name || input.tool ||
|
|
5613
|
+
(input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
|
|
5614
|
+
).toLowerCase();
|
|
5615
|
+
const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
|
|
5616
|
+
// Collect only PATH-bearing field values (not free-form content), so a doc
|
|
5617
|
+
// that merely mentions ".planning/" in its body is never falsely blocked.
|
|
5618
|
+
const paths = [];
|
|
5619
|
+
const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
|
5620
|
+
const walk = (v, depth) => {
|
|
5621
|
+
if (depth > 5 || paths.length > 64) return;
|
|
5622
|
+
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
|
|
5623
|
+
if (v && typeof v === 'object') {
|
|
5624
|
+
for (const k of Object.keys(v)) {
|
|
5625
|
+
const val = v[k];
|
|
5626
|
+
if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
|
|
5627
|
+
else walk(val, depth + 1);
|
|
5628
|
+
}
|
|
5629
|
+
}
|
|
5630
|
+
};
|
|
5631
|
+
walk(input, 0);
|
|
5632
|
+
const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
|
|
5633
|
+
if (isWrite && paths.some(isPlanningPath)) {
|
|
5634
|
+
return process.stdout.write(JSON.stringify({
|
|
5635
|
+
cancel: true,
|
|
5636
|
+
errorMessage:
|
|
5637
|
+
'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
|
|
5638
|
+
}));
|
|
5639
|
+
}
|
|
5640
|
+
} catch { /* fall through to allow */ }
|
|
5641
|
+
return allow();
|
|
5642
|
+
});
|
|
5643
|
+
`;
|
|
5644
|
+
}
|
|
5645
|
+
|
|
5646
|
+
/**
|
|
5647
|
+
* Merge the GSD AGENTS.md block into an existing file (or create it), preserving
|
|
5648
|
+
* any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
|
|
5649
|
+
*/
|
|
5650
|
+
function mergeGsdAgentsMd(filePath, gsdContent) {
|
|
5651
|
+
const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER;
|
|
5652
|
+
|
|
5653
|
+
if (!fs.existsSync(filePath)) {
|
|
5654
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
5655
|
+
fs.writeFileSync(filePath, gsdBlock + '\n');
|
|
5656
|
+
return;
|
|
5657
|
+
}
|
|
5658
|
+
|
|
5659
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
5660
|
+
const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
|
|
5661
|
+
const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
|
|
5662
|
+
|
|
5663
|
+
if (openIndex !== -1 && closeIndex !== -1) {
|
|
5664
|
+
const before = existing.substring(0, openIndex).trimEnd();
|
|
5665
|
+
const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
|
|
5666
|
+
let newContent = '';
|
|
5667
|
+
if (before) newContent += before + '\n\n';
|
|
5668
|
+
newContent += gsdBlock;
|
|
5669
|
+
if (after) newContent += '\n\n' + after;
|
|
5670
|
+
newContent += '\n';
|
|
5671
|
+
fs.writeFileSync(filePath, newContent);
|
|
5672
|
+
return;
|
|
5673
|
+
}
|
|
5674
|
+
|
|
5675
|
+
fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n');
|
|
5676
|
+
}
|
|
5677
|
+
|
|
5678
|
+
/**
|
|
5679
|
+
* Strip the GSD block from AGENTS.md content. Returns null if the file became
|
|
5680
|
+
* empty (was GSD-only), the unchanged content if no markers were found, or the
|
|
5681
|
+
* cleaned content otherwise.
|
|
5682
|
+
*/
|
|
5683
|
+
function stripGsdFromAgentsMd(content) {
|
|
5684
|
+
const openIndex = content.indexOf(GSD_AGENTS_MD_MARKER);
|
|
5685
|
+
const closeIndex = content.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
|
|
5686
|
+
if (openIndex !== -1 && closeIndex !== -1) {
|
|
5687
|
+
const before = content.substring(0, openIndex).trimEnd();
|
|
5688
|
+
const after = content.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
|
|
5689
|
+
const cleaned = (before + (before && after ? '\n\n' : '') + after).trim();
|
|
5690
|
+
if (!cleaned) return null;
|
|
5691
|
+
return cleaned + '\n';
|
|
5692
|
+
}
|
|
5693
|
+
return content;
|
|
5694
|
+
}
|
|
5695
|
+
|
|
5696
|
+
/**
|
|
5697
|
+
* Write the full Cline runtime artifact set (directory-form rules + PreToolUse
|
|
5698
|
+
* hook) into targetDir, migrating a legacy single-file `.clinerules` if present.
|
|
5699
|
+
* For global installs, also merge the cross-tool ~/.agents/AGENTS.md target.
|
|
5700
|
+
*
|
|
5701
|
+
* Returns the list of manifest-relative paths written under targetDir (so the
|
|
5702
|
+
* caller can hash-track them).
|
|
5703
|
+
*/
|
|
5704
|
+
function writeClineArtifacts(targetDir, isGlobalInstall) {
|
|
5705
|
+
const written = [];
|
|
5706
|
+
const clinerulesDir = path.join(targetDir, '.clinerules');
|
|
5707
|
+
|
|
5708
|
+
// Migrate a pre-#787 single-file `.clinerules` — a path cannot be both a
|
|
5709
|
+
// file and a directory, so the legacy file must be removed first. The legacy
|
|
5710
|
+
// file is GSD-authored (the installer wrote its full contents with no user
|
|
5711
|
+
// merge surface), so replacing it with the newer directory form is the
|
|
5712
|
+
// intended upgrade. Use lstat so a symlink is unlinked in place rather than
|
|
5713
|
+
// followed (which would write GSD files through the link into an external dir).
|
|
5714
|
+
try {
|
|
5715
|
+
if (fs.existsSync(clinerulesDir)) {
|
|
5716
|
+
const st = fs.lstatSync(clinerulesDir);
|
|
5717
|
+
if (st.isFile() || st.isSymbolicLink()) {
|
|
5718
|
+
fs.unlinkSync(clinerulesDir);
|
|
5719
|
+
console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
|
|
5720
|
+
}
|
|
5721
|
+
}
|
|
5722
|
+
} catch { /* best-effort migration */ }
|
|
5723
|
+
|
|
5724
|
+
fs.mkdirSync(clinerulesDir, { recursive: true });
|
|
5725
|
+
fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
|
|
5726
|
+
written.push('.clinerules/gsd.md');
|
|
5727
|
+
console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
|
|
5728
|
+
|
|
5729
|
+
const hooksDir = path.join(clinerulesDir, 'hooks');
|
|
5730
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
5731
|
+
const hookPath = path.join(hooksDir, 'PreToolUse');
|
|
5732
|
+
fs.writeFileSync(hookPath, buildClinePreToolUseHook());
|
|
5733
|
+
try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ }
|
|
5734
|
+
written.push('.clinerules/hooks/PreToolUse');
|
|
5735
|
+
console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
|
|
5736
|
+
|
|
5737
|
+
// Global cross-tool instruction target. Cline reads ~/.agents/AGENTS.md
|
|
5738
|
+
// (docs.cline.bot/customization/cline-rules). Merge-safe so we never clobber
|
|
5739
|
+
// a user's or another tool's AGENTS.md. Tracked via markers (like copilot),
|
|
5740
|
+
// not the per-configDir manifest, since it lives outside configDir.
|
|
5741
|
+
if (isGlobalInstall) {
|
|
5742
|
+
try {
|
|
5743
|
+
const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
|
|
5744
|
+
mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
|
|
5745
|
+
console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
|
|
5746
|
+
} catch (err) {
|
|
5747
|
+
console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`);
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
5750
|
+
|
|
5751
|
+
return written;
|
|
5752
|
+
}
|
|
5753
|
+
|
|
5754
|
+
// ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
|
|
5755
|
+
//
|
|
5756
|
+
// Cursor v2.4+ supports a hooks.json lifecycle hook system. GSD registers two
|
|
5757
|
+
// managed command hooks:
|
|
5758
|
+
// sessionStart → gsd-cursor-session-start.js (context injection)
|
|
5759
|
+
// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor)
|
|
5760
|
+
//
|
|
5761
|
+
// hooks.json schema:
|
|
5762
|
+
// { "version": 1, "hooks": { "<event>": [ { "type": "command", "command": "<path>" } ] } }
|
|
5763
|
+
//
|
|
5764
|
+
// Location:
|
|
5765
|
+
// Global: ~/.cursor/hooks.json
|
|
5766
|
+
// Local: <project-root>/.cursor/hooks.json
|
|
5767
|
+
//
|
|
5768
|
+
// GSD entries are identified by a top-level `"gsd-managed": true` field on
|
|
5769
|
+
// each hook entry. Non-GSD entries are preserved. The reconciler is idempotent
|
|
5770
|
+
// (safe to re-run) and preserves user-owned entries in the file.
|
|
5771
|
+
//
|
|
5772
|
+
// References: https://cursor.com/docs/hooks
|
|
5773
|
+
|
|
5774
|
+
/**
|
|
5775
|
+
* Build a managed Cursor hook entry for a given hook script path.
|
|
5776
|
+
*
|
|
5777
|
+
* @param {string} scriptPath - Absolute path to the hook script
|
|
5778
|
+
* @returns {object} Cursor hook entry object
|
|
5779
|
+
*/
|
|
5780
|
+
function buildCursorHookEntry(scriptPath) {
|
|
5781
|
+
return {
|
|
5782
|
+
type: 'command',
|
|
5783
|
+
command: scriptPath.replace(/\\/g, '/'),
|
|
5784
|
+
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
5785
|
+
};
|
|
5786
|
+
}
|
|
5787
|
+
|
|
5788
|
+
/**
|
|
5789
|
+
* Return true if a Cursor hook entry is GSD-managed.
|
|
5790
|
+
* Detection: presence of the GSD_CURSOR_HOOK_MARKER sentinel field.
|
|
5791
|
+
*
|
|
5792
|
+
* @param {object} entry - A hooks array element from hooks.json
|
|
5793
|
+
* @returns {boolean}
|
|
5794
|
+
*/
|
|
5795
|
+
function isManagedCursorHookEntry(entry) {
|
|
5796
|
+
return Boolean(entry && typeof entry === 'object' && entry[GSD_CURSOR_HOOK_MARKER]);
|
|
5797
|
+
}
|
|
5798
|
+
|
|
5799
|
+
/**
|
|
5800
|
+
* Reconcile the GSD-managed entries in a Cursor hooks.json file.
|
|
5801
|
+
*
|
|
5802
|
+
* Supports both known hooks.json shapes:
|
|
5803
|
+
* 1) { "version": 1, "hooks": { "sessionStart": [...], "postToolUse": [...] } }
|
|
5804
|
+
* 2) { "sessionStart": [...], "postToolUse": [...] } (no wrapper object)
|
|
5805
|
+
*
|
|
5806
|
+
* Managed entries (those with GSD_CURSOR_HOOK_MARKER) are removed then
|
|
5807
|
+
* re-added if managedEntries is non-null/non-empty. User-owned entries are
|
|
5808
|
+
* preserved. File is written atomically only when content changes.
|
|
5809
|
+
*
|
|
5810
|
+
* @param {string} hooksJsonPath - Absolute path to the hooks.json file
|
|
5811
|
+
* @param {{ sessionStart?: object|null, postToolUse?: object|null }|null} managedEntries
|
|
5812
|
+
* Map from event name to the new hook entry to register (or null to remove).
|
|
5813
|
+
* Pass null for the whole param to remove all managed entries.
|
|
5814
|
+
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
5815
|
+
*/
|
|
5816
|
+
function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
|
|
5817
|
+
let parsed = {};
|
|
5818
|
+
let currentContent = null;
|
|
5819
|
+
|
|
5820
|
+
if (fs.existsSync(hooksJsonPath)) {
|
|
5821
|
+
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
|
|
5822
|
+
currentContent = raw;
|
|
5823
|
+
if (raw.trim()) {
|
|
5824
|
+
try {
|
|
5825
|
+
parsed = JSON.parse(raw);
|
|
5826
|
+
} catch (err) {
|
|
5827
|
+
throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
|
|
5828
|
+
}
|
|
5829
|
+
}
|
|
5830
|
+
}
|
|
5831
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
|
|
5832
|
+
|
|
5833
|
+
// Cursor's canonical hooks.json schema is { "version": 1, "hooks": { ... } }.
|
|
5834
|
+
// GSD always writes (and migrates to) the nested shape so Cursor reads it correctly.
|
|
5835
|
+
// The flat shape { "sessionStart": [...] } is accepted on read for backwards compat
|
|
5836
|
+
// with manually-written files, but the output always uses the nested form.
|
|
5837
|
+
const hasNestedHooksObject =
|
|
5838
|
+
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
|
|
5839
|
+
if (!hasNestedHooksObject) {
|
|
5840
|
+
// Migrate flat shape (or empty {}) to nested: lift event keys into hooks:{}.
|
|
5841
|
+
const eventKeys = ['sessionStart', 'postToolUse'];
|
|
5842
|
+
const lifted = {};
|
|
5843
|
+
for (const k of eventKeys) {
|
|
5844
|
+
if (Array.isArray(parsed[k])) {
|
|
5845
|
+
lifted[k] = parsed[k];
|
|
5846
|
+
delete parsed[k];
|
|
5847
|
+
}
|
|
5848
|
+
}
|
|
5849
|
+
parsed.hooks = lifted;
|
|
5850
|
+
}
|
|
5851
|
+
if (!parsed.version) parsed.version = 1;
|
|
5852
|
+
const hookTable = parsed.hooks;
|
|
5853
|
+
|
|
5854
|
+
// Events GSD manages.
|
|
5855
|
+
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
|
|
5856
|
+
const entries = managedEntries || {};
|
|
5857
|
+
|
|
5858
|
+
for (const event of MANAGED_EVENTS) {
|
|
5859
|
+
const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
|
|
5860
|
+
// Strip all prior GSD-managed entries for this event.
|
|
5861
|
+
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
|
|
5862
|
+
const newEntry = entries[event] || null;
|
|
5863
|
+
if (newEntry) {
|
|
5864
|
+
hookTable[event] = [...userOwned, newEntry];
|
|
5865
|
+
} else {
|
|
5866
|
+
// Remove-only: keep user entries, or delete the key if it would be empty.
|
|
5867
|
+
if (userOwned.length > 0) {
|
|
5868
|
+
hookTable[event] = userOwned;
|
|
5869
|
+
} else {
|
|
5870
|
+
delete hookTable[event];
|
|
5871
|
+
}
|
|
5872
|
+
}
|
|
5873
|
+
}
|
|
5874
|
+
|
|
5875
|
+
// hookTable is parsed.hooks (always nested now); no reassignment needed.
|
|
5876
|
+
// Write only if content changed or if we're creating the file for the first time.
|
|
5877
|
+
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
5878
|
+
const changed = currentContent !== nextContent;
|
|
5879
|
+
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
5880
|
+
if (shouldWrite) {
|
|
5881
|
+
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
|
5882
|
+
}
|
|
5883
|
+
|
|
5884
|
+
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
|
|
5885
|
+
}
|
|
5886
|
+
|
|
5887
|
+
/**
|
|
5888
|
+
* #777 — Write GSD-managed Cursor lifecycle hooks into <targetDir>/hooks.json.
|
|
5889
|
+
*
|
|
5890
|
+
* Both managed hook scripts (gsd-cursor-session-start.js, gsd-cursor-post-tool.js)
|
|
5891
|
+
* are copied from the GSD hooks/ source to <targetDir>/hooks/ first, so the
|
|
5892
|
+
* hooks.json entries never reference a script that wasn't installed.
|
|
5893
|
+
*
|
|
5894
|
+
* @param {string} targetDir - The Cursor config dir (global: ~/.cursor; local: .cursor)
|
|
5895
|
+
* @param {string} src - The GSD install source root (for copying hook scripts)
|
|
5896
|
+
* @param {{ absoluteRunner?: string|null }} opts
|
|
5897
|
+
* @returns {{ hooksJsonPath: string, changed: boolean }}
|
|
5898
|
+
*/
|
|
5899
|
+
function writeCursorHooksJson(targetDir, src, opts) {
|
|
5900
|
+
opts = opts || {};
|
|
5901
|
+
const hooksDir = path.join(targetDir, 'hooks');
|
|
5902
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
5903
|
+
|
|
5904
|
+
// Copy the two GSD-managed hook scripts from the GSD source hooks/ directory.
|
|
5905
|
+
// Apply the same /gsd:/gi → gsd- rewrite used by copyWithPathReplacement for Cursor
|
|
5906
|
+
// JS files, so the installed hook scripts contain no /gsd: colon refs (bug-376 2b).
|
|
5907
|
+
// Track which scripts were successfully installed so we never register a hook entry
|
|
5908
|
+
// that references a script that wasn't copied (dangling command guard).
|
|
5909
|
+
const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
|
|
5910
|
+
const srcHooksDir = path.join(src, 'hooks');
|
|
5911
|
+
const installedScripts = new Set();
|
|
5912
|
+
for (const script of hookScripts) {
|
|
5913
|
+
const srcPath = path.join(srcHooksDir, script);
|
|
5914
|
+
const destPath = path.join(hooksDir, script);
|
|
5915
|
+
if (fs.existsSync(srcPath)) {
|
|
5916
|
+
let content = fs.readFileSync(srcPath, 'utf8');
|
|
5917
|
+
// Rewrite /gsd:<cmd> → gsd-<cmd> so installed hook scripts are consistent
|
|
5918
|
+
// with the Cursor convention (no colon-form slash commands in agent context).
|
|
5919
|
+
content = content.replace(/gsd:/gi, 'gsd-');
|
|
5920
|
+
fs.writeFileSync(destPath, content);
|
|
5921
|
+
try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
|
|
5922
|
+
installedScripts.add(script);
|
|
5923
|
+
}
|
|
5924
|
+
}
|
|
5925
|
+
|
|
5926
|
+
// Build command strings using the same buildHookCommand helper used by other runtimes.
|
|
5927
|
+
// buildHookCommand resolves the node runner + emits "<runner>" "<targetDir>/hooks/<name>".
|
|
5928
|
+
const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
|
|
5929
|
+
// buildHookCommand('gsd-cursor-session-start.js', ...): sessionStart → context injection
|
|
5930
|
+
// Only register the hook entry if the script was actually installed (dangling guard).
|
|
5931
|
+
const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
|
|
5932
|
+
? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
|
|
5933
|
+
: null;
|
|
5934
|
+
// buildHookCommand('gsd-cursor-post-tool.js', ...): postToolUse → STATE.md update monitor
|
|
5935
|
+
const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
|
|
5936
|
+
? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
|
|
5937
|
+
: null;
|
|
5938
|
+
|
|
5939
|
+
// Build managed entries; skip events whose command couldn't be resolved (e.g. no node).
|
|
5940
|
+
const managedEntries = {};
|
|
5941
|
+
if (sessionStartCmd) {
|
|
5942
|
+
managedEntries.sessionStart = {
|
|
5943
|
+
type: 'command',
|
|
5944
|
+
command: sessionStartCmd,
|
|
5945
|
+
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
5946
|
+
};
|
|
5947
|
+
}
|
|
5948
|
+
if (postToolCmd) {
|
|
5949
|
+
managedEntries.postToolUse = {
|
|
5950
|
+
type: 'command',
|
|
5951
|
+
command: postToolCmd,
|
|
5952
|
+
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
5953
|
+
};
|
|
5954
|
+
}
|
|
5955
|
+
|
|
5956
|
+
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
5957
|
+
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
|
5958
|
+
return { hooksJsonPath, changed: result.changed };
|
|
5959
|
+
}
|
|
5960
|
+
|
|
5961
|
+
/**
|
|
5962
|
+
* Remove all GSD-managed Cursor lifecycle hook entries from hooks.json.
|
|
5963
|
+
* User-owned entries are preserved. If the file becomes empty, it is removed.
|
|
5964
|
+
*
|
|
5965
|
+
* @param {string} targetDir - The Cursor config dir
|
|
5966
|
+
* @returns {{ changed: boolean }}
|
|
5967
|
+
*/
|
|
5968
|
+
function removeCursorHooksJson(targetDir) {
|
|
5969
|
+
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
5970
|
+
if (!fs.existsSync(hooksJsonPath)) return { changed: false };
|
|
5971
|
+
const result = reconcileCursorHooksJson(hooksJsonPath, null);
|
|
5972
|
+
// If the resulting file has no meaningful hook content, remove it.
|
|
5973
|
+
// A file is "empty" if it contains only the scaffolding (version, empty hooks
|
|
5974
|
+
// object, or a bare {}) with no user-authored hook entries.
|
|
5975
|
+
if (result.changed) {
|
|
5976
|
+
try {
|
|
5977
|
+
const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
|
|
5978
|
+
const parsed = JSON.parse(contentRaw);
|
|
5979
|
+
// reconcileCursorHooksJson always writes the nested { version, hooks:{} } shape.
|
|
5980
|
+
// The file is "empty" when there are no remaining hook events with entries.
|
|
5981
|
+
const hookTable = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
|
|
5982
|
+
? parsed.hooks
|
|
5983
|
+
: {};
|
|
5984
|
+
const hasAnyEvents = Object.keys(hookTable).some(
|
|
5985
|
+
(k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0,
|
|
5986
|
+
);
|
|
5987
|
+
if (!hasAnyEvents) {
|
|
5988
|
+
fs.unlinkSync(hooksJsonPath);
|
|
5989
|
+
return { changed: true };
|
|
5990
|
+
}
|
|
5991
|
+
} catch { /* best-effort: leave the file */ }
|
|
5992
|
+
}
|
|
5993
|
+
return { changed: result.changed };
|
|
5994
|
+
}
|
|
5995
|
+
|
|
5996
|
+
/**
|
|
5997
|
+
* #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
|
|
5998
|
+
*
|
|
5999
|
+
* Returns the verbatim JSON shape Copilot CLI expects:
|
|
6000
|
+
* { version: 1, hooks: { sessionStart: [ <hook entry> ] } }
|
|
6001
|
+
*
|
|
6002
|
+
* The sessionStart entry is a `command` hook whose `bash`/`powershell` bodies
|
|
6003
|
+
* run inline (no external script file), so the config can never reference a
|
|
6004
|
+
* hook script that the installer did not also install — it is self-contained
|
|
6005
|
+
* by construction. The command is advisory-only (always exits 0) and orients
|
|
6006
|
+
* the agent toward the project's GSD planning state at session start.
|
|
6007
|
+
*
|
|
6008
|
+
* @returns {object} Copilot hooks-configuration object
|
|
6009
|
+
*/
|
|
6010
|
+
function buildCopilotHookConfig() {
|
|
6011
|
+
return {
|
|
6012
|
+
version: 1,
|
|
6013
|
+
hooks: {
|
|
6014
|
+
sessionStart: [
|
|
6015
|
+
{
|
|
6016
|
+
type: 'command',
|
|
6017
|
+
bash: GSD_COPILOT_SESSION_HOOK_BASH,
|
|
6018
|
+
powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
|
|
6019
|
+
timeoutSec: 10,
|
|
6020
|
+
},
|
|
6021
|
+
],
|
|
6022
|
+
},
|
|
6023
|
+
};
|
|
6024
|
+
}
|
|
6025
|
+
|
|
6026
|
+
/**
|
|
6027
|
+
* #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime
|
|
6028
|
+
* config dir (`<targetDir>/hooks/gsd-session.json`). For local installs
|
|
6029
|
+
* targetDir is `.github` (→ `.github/hooks/`); for global installs it is
|
|
6030
|
+
* `~/.copilot` (→ `~/.copilot/hooks/`) — both are valid Copilot hook locations.
|
|
6031
|
+
*
|
|
6032
|
+
* The managed file is fully owned by GSD, so it is overwritten wholesale on
|
|
6033
|
+
* every install (idempotent). User-authored sibling `*.json` hook files in the
|
|
6034
|
+
* same directory are untouched.
|
|
6035
|
+
*
|
|
6036
|
+
* @param {string} targetDir - The Copilot config dir
|
|
6037
|
+
* @returns {string} The path the hook config was written to
|
|
6038
|
+
*/
|
|
6039
|
+
function writeCopilotHookConfig(targetDir) {
|
|
6040
|
+
const hooksDir = path.join(targetDir, 'hooks');
|
|
6041
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
6042
|
+
const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE);
|
|
6043
|
+
fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
|
|
6044
|
+
return hookPath;
|
|
6045
|
+
}
|
|
6046
|
+
|
|
5224
6047
|
/**
|
|
5225
6048
|
* Generate config.toml and per-agent .toml files for Codex.
|
|
5226
6049
|
* Reads agent .md files from source, extracts metadata, writes .toml configs.
|
|
@@ -5393,7 +6216,7 @@ function convertSlashCommandsToGeminiMentions(content) {
|
|
|
5393
6216
|
});
|
|
5394
6217
|
}
|
|
5395
6218
|
|
|
5396
|
-
function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
|
|
6219
|
+
function convertClaudeToGeminiMarkdown(content, { isCommand = false, commandName = null } = {}) {
|
|
5397
6220
|
// Apply Gemini-specific slash command namespacing
|
|
5398
6221
|
let converted = convertSlashCommandsToGeminiMentions(content);
|
|
5399
6222
|
// Gemini CLI does not expose Claude's AskUserQuestion tool. Convert body
|
|
@@ -5405,8 +6228,9 @@ function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
|
|
|
5405
6228
|
converted = stripSubTags(converted);
|
|
5406
6229
|
|
|
5407
6230
|
if (isCommand) {
|
|
5408
|
-
// Convert to Gemini TOML format
|
|
5409
|
-
|
|
6231
|
+
// Convert to Gemini TOML format (threads the command name so per-command
|
|
6232
|
+
// enrichment — e.g. the #778 live-state injection — can target a command).
|
|
6233
|
+
converted = convertClaudeToGeminiToml(converted, { commandName });
|
|
5410
6234
|
}
|
|
5411
6235
|
|
|
5412
6236
|
return converted;
|
|
@@ -5841,12 +6665,85 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
|
|
|
5841
6665
|
return `---\n${newFrontmatter}\n---${body}`;
|
|
5842
6666
|
}
|
|
5843
6667
|
|
|
6668
|
+
/**
|
|
6669
|
+
* Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo),
|
|
6670
|
+
* which share a config schema (Kilo derives from OpenCode). OpenCode discovers
|
|
6671
|
+
* skills as `skills/<name>/SKILL.md` and Kilo follows the same layout
|
|
6672
|
+
* (https://opencode.ai/docs/skills, https://kilo.ai/docs/customize/skills).
|
|
6673
|
+
*
|
|
6674
|
+
* The skill body reuses the runtime's command-frontmatter converter for tool,
|
|
6675
|
+
* path, and `/gsd:`→`/gsd-` body rewrites, then rebuilds a minimal skill
|
|
6676
|
+
* frontmatter: only `name` (lowercase-hyphen, must match the containing
|
|
6677
|
+
* directory) and `description` (1–1024 chars) are emitted, per the OpenCode
|
|
6678
|
+
* skill spec. The command's `tools:`/`permission:` block is intentionally
|
|
6679
|
+
* dropped — OpenCode skills are loaded on-demand via the native skill tool and
|
|
6680
|
+
* inherit the calling agent's permissions.
|
|
6681
|
+
*
|
|
6682
|
+
* @param {string} content - Claude command markdown (with YAML frontmatter)
|
|
6683
|
+
* @param {string} skillName - Skill directory name (e.g. gsd-help)
|
|
6684
|
+
* @param {(content: string) => string} frontmatterConverter - runtime command converter
|
|
6685
|
+
* @returns {string} SKILL.md content
|
|
6686
|
+
*/
|
|
6687
|
+
function convertClaudeCommandToOpencodeFamilySkill(content, skillName, frontmatterConverter) {
|
|
6688
|
+
const converted = frontmatterConverter(content);
|
|
6689
|
+
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
|
6690
|
+
let description = `Run GSD workflow ${skillName}.`;
|
|
6691
|
+
if (frontmatter) {
|
|
6692
|
+
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
|
|
6693
|
+
if (maybeDescription) {
|
|
6694
|
+
description = maybeDescription;
|
|
6695
|
+
}
|
|
6696
|
+
}
|
|
6697
|
+
description = toSingleLine(description);
|
|
6698
|
+
// OpenCode skill descriptions must be 1–1024 characters.
|
|
6699
|
+
if (description.length > 1024) {
|
|
6700
|
+
description = `${description.slice(0, 1021)}...`;
|
|
6701
|
+
}
|
|
6702
|
+
// `name` must be lowercase alphanumeric with single-hyphen separators and
|
|
6703
|
+
// match the containing directory name (the staged dir is `${skillName}/`).
|
|
6704
|
+
const name = yamlIdentifier(skillName);
|
|
6705
|
+
return `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trimStart()}`;
|
|
6706
|
+
}
|
|
6707
|
+
|
|
6708
|
+
/**
|
|
6709
|
+
* Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
|
|
6710
|
+
* Thin wrapper over the shared OpenCode-family writer.
|
|
6711
|
+
*/
|
|
6712
|
+
function convertClaudeCommandToOpencodeSkill(content, skillName) {
|
|
6713
|
+
return convertClaudeCommandToOpencodeFamilySkill(
|
|
6714
|
+
content,
|
|
6715
|
+
skillName,
|
|
6716
|
+
(c) => convertClaudeToOpencodeFrontmatter(c),
|
|
6717
|
+
);
|
|
6718
|
+
}
|
|
6719
|
+
|
|
6720
|
+
/**
|
|
6721
|
+
* Convert a Claude command (.md) to a Kilo skill (SKILL.md).
|
|
6722
|
+
* Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
|
|
6723
|
+
*/
|
|
6724
|
+
function convertClaudeCommandToKiloSkill(content, skillName) {
|
|
6725
|
+
return convertClaudeCommandToOpencodeFamilySkill(
|
|
6726
|
+
content,
|
|
6727
|
+
skillName,
|
|
6728
|
+
(c) => convertClaudeToKiloFrontmatter(c),
|
|
6729
|
+
);
|
|
6730
|
+
}
|
|
6731
|
+
|
|
5844
6732
|
/**
|
|
5845
6733
|
* Convert Claude Code markdown command to Gemini TOML format
|
|
5846
6734
|
* @param {string} content - Markdown file content with YAML frontmatter
|
|
5847
6735
|
* @returns {string} - TOML content
|
|
5848
6736
|
*/
|
|
5849
|
-
function convertClaudeToGeminiToml(content) {
|
|
6737
|
+
function convertClaudeToGeminiToml(content, { commandName = null } = {}) {
|
|
6738
|
+
// #778 (c) — Gemini {{args}} interpolation. Claude's $ARGUMENTS placeholder
|
|
6739
|
+
// maps to Gemini's {{args}} so inline argument references interpolate into the
|
|
6740
|
+
// command body instead of being emitted as a dead literal. Applied before
|
|
6741
|
+
// frontmatter parsing so every return path benefits (a command's frontmatter
|
|
6742
|
+
// never contains $ARGUMENTS, so this is body-only in practice). Gemini injects
|
|
6743
|
+
// {{args}} as typed outside shell blocks; we never place it inside a !{...}
|
|
6744
|
+
// block, so there is no shell-escaping/injection interaction.
|
|
6745
|
+
content = content.replace(/\$ARGUMENTS\b/g, '{{args}}');
|
|
6746
|
+
|
|
5850
6747
|
// Check if content has frontmatter
|
|
5851
6748
|
if (!content.startsWith('---')) {
|
|
5852
6749
|
return `prompt = ${JSON.stringify(content)}\n`;
|
|
@@ -5858,7 +6755,27 @@ function convertClaudeToGeminiToml(content) {
|
|
|
5858
6755
|
}
|
|
5859
6756
|
|
|
5860
6757
|
const frontmatter = content.substring(3, endIndex).trim();
|
|
5861
|
-
|
|
6758
|
+
let body = content.substring(endIndex + 3).trim();
|
|
6759
|
+
|
|
6760
|
+
// #778 (c) — Gemini !{...} dynamic-output injection for the situational
|
|
6761
|
+
// `progress` command (GSD's status/dashboard surface). Inject the live
|
|
6762
|
+
// .planning/STATE.md so the model sees current project state without relying
|
|
6763
|
+
// on session memory.
|
|
6764
|
+
//
|
|
6765
|
+
// SECURITY: the shell command is a FIXED `cat` with NO interpolated user
|
|
6766
|
+
// input — no {{args}} appears inside the block — so there is no
|
|
6767
|
+
// shell-injection vector. Gemini still shows its standard per-invocation
|
|
6768
|
+
// confirmation dialog (verified behavior). `2>/dev/null` keeps an
|
|
6769
|
+
// uninitialized project (missing STATE.md) from injecting stderr noise.
|
|
6770
|
+
// Braces inside the block are balanced (none present), per Gemini's parser
|
|
6771
|
+
// requirement. The append happens AFTER the {{args}} mapping above so the
|
|
6772
|
+
// injected block can never accidentally carry interpolated arguments.
|
|
6773
|
+
if (commandName === 'progress') {
|
|
6774
|
+
body += '\n\n## Live project state\n'
|
|
6775
|
+
+ 'Current contents of `.planning/STATE.md` '
|
|
6776
|
+
+ '(empty if the project is not yet initialized):\n\n'
|
|
6777
|
+
+ '!{cat .planning/STATE.md 2>/dev/null}\n';
|
|
6778
|
+
}
|
|
5862
6779
|
|
|
5863
6780
|
// Extract description from frontmatter
|
|
5864
6781
|
let description = '';
|
|
@@ -5893,6 +6810,31 @@ function convertClaudeToGeminiToml(content) {
|
|
|
5893
6810
|
* @param {string} pathPrefix - Path prefix for file references
|
|
5894
6811
|
* @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
|
|
5895
6812
|
*/
|
|
6813
|
+
/**
|
|
6814
|
+
* Apply OpenCode-family (`opencode`/`kilo`) `@file` path-prefix rewrites to a
|
|
6815
|
+
* RAW Claude command/skill body, BEFORE the frontmatter converter runs.
|
|
6816
|
+
*
|
|
6817
|
+
* This is the single source of truth shared by copyFlattenedCommands (commands)
|
|
6818
|
+
* and installOpencodeFamilySkills (skills) so the two surfaces produce identical
|
|
6819
|
+
* path references. Applying pathPrefix pre-conversion (rather than rewriting an
|
|
6820
|
+
* already-converted body) is what avoids the converter's hardcoded default
|
|
6821
|
+
* config dir leaking into --local / --config-dir installs, and the
|
|
6822
|
+
* prefix-overlap double-rewrite hazard for custom dirs like `kilo-alt`. (#784)
|
|
6823
|
+
*
|
|
6824
|
+
* @param {string} content - raw Claude command markdown
|
|
6825
|
+
* @param {string} runtime - 'opencode' or 'kilo'
|
|
6826
|
+
* @param {string} pathPrefix - trailing-slash install-target prefix
|
|
6827
|
+
* @returns {string}
|
|
6828
|
+
*/
|
|
6829
|
+
function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
|
|
6830
|
+
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
6831
|
+
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
|
6832
|
+
content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
|
|
6833
|
+
content = content.replace(/~\/\.opencode\//g, pathPrefix);
|
|
6834
|
+
content = content.replace(/~\/\.kilo\//g, pathPrefix);
|
|
6835
|
+
return content;
|
|
6836
|
+
}
|
|
6837
|
+
|
|
5896
6838
|
function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
|
|
5897
6839
|
if (!fs.existsSync(srcDir)) {
|
|
5898
6840
|
return;
|
|
@@ -5925,16 +6867,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
|
|
|
5925
6867
|
const destPath = path.join(destDir, destName);
|
|
5926
6868
|
|
|
5927
6869
|
let content = fs.readFileSync(srcPath, 'utf8');
|
|
5928
|
-
|
|
5929
|
-
const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
|
|
5930
|
-
const localClaudeRegex = /\.\/\.claude\//g;
|
|
5931
|
-
const opencodeDirRegex = /~\/\.opencode\//g;
|
|
5932
|
-
const kiloDirRegex = /~\/\.kilo\//g;
|
|
5933
|
-
content = content.replace(globalClaudeRegex, pathPrefix);
|
|
5934
|
-
content = content.replace(globalClaudeHomeRegex, pathPrefix);
|
|
5935
|
-
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
|
|
5936
|
-
content = content.replace(opencodeDirRegex, pathPrefix);
|
|
5937
|
-
content = content.replace(kiloDirRegex, pathPrefix);
|
|
6870
|
+
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
5938
6871
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
5939
6872
|
content = runtime === 'kilo'
|
|
5940
6873
|
? convertClaudeToKiloFrontmatter(content)
|
|
@@ -6119,7 +7052,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
|
|
6119
7052
|
if (runtime) {
|
|
6120
7053
|
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
|
|
6121
7054
|
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
|
6122
|
-
if (!skillsKindEntry) return false; // runtime has no skills layout (e.g. cline)
|
|
7055
|
+
if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local)
|
|
6123
7056
|
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
|
|
6124
7057
|
skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName);
|
|
6125
7058
|
} else {
|
|
@@ -6176,6 +7109,46 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
|
|
|
6176
7109
|
walkAndRewrite(stagedDir);
|
|
6177
7110
|
}
|
|
6178
7111
|
|
|
7112
|
+
/**
|
|
7113
|
+
* Apply per-runtime content rewrites to flat .md files in a staged commands dir.
|
|
7114
|
+
* Used for runtimes that have a commandsKind in their layout and need content rewrites
|
|
7115
|
+
* (e.g. augment — replaces ~/.claude/ paths and applies branding conversions).
|
|
7116
|
+
*
|
|
7117
|
+
* IMPORTANT: `stageSkillsForProfile()` returns the original source directory unchanged
|
|
7118
|
+
* on a full/default profile (skills === '*'). This function MUST NOT mutate that source
|
|
7119
|
+
* directory. It always copies to a temp dir first, rewrites there, and returns the new
|
|
7120
|
+
* path so the caller installs from the temp copy, not the source.
|
|
7121
|
+
*
|
|
7122
|
+
* @param {string} stagedDir directory of staged flat .md command files (may be source dir)
|
|
7123
|
+
* @param {string} runtime
|
|
7124
|
+
* @param {string} pathPrefix
|
|
7125
|
+
* @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
|
|
7126
|
+
*/
|
|
7127
|
+
function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) {
|
|
7128
|
+
if (!fs.existsSync(stagedDir)) return stagedDir;
|
|
7129
|
+
// Always copy to a temp dir — stageSkillsForProfile() returns the original source
|
|
7130
|
+
// dir on full/default profile (skills === '*'), so writing in-place would corrupt the
|
|
7131
|
+
// package source. A temp copy is unconditional to keep the code simple and safe.
|
|
7132
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cmd-rewrites-'));
|
|
7133
|
+
try {
|
|
7134
|
+
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
|
|
7135
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
7136
|
+
let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
|
|
7137
|
+
content = _applyRuntimeRewrites(content, runtime, pathPrefix);
|
|
7138
|
+
// For augment commands, apply the markdown conversion so tool references
|
|
7139
|
+
// and skill paths use Augment equivalents.
|
|
7140
|
+
if (runtime === 'augment') {
|
|
7141
|
+
content = convertClaudeToAugmentMarkdown(content);
|
|
7142
|
+
}
|
|
7143
|
+
fs.writeFileSync(path.join(tempDir, entry.name), content);
|
|
7144
|
+
}
|
|
7145
|
+
} catch (err) {
|
|
7146
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
7147
|
+
throw err;
|
|
7148
|
+
}
|
|
7149
|
+
return tempDir;
|
|
7150
|
+
}
|
|
7151
|
+
|
|
6179
7152
|
/**
|
|
6180
7153
|
* Apply the per-runtime rewrite table to a single content string.
|
|
6181
7154
|
* Extracted so it can be unit-tested independently of the filesystem walk.
|
|
@@ -6198,6 +7171,22 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
6198
7171
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
6199
7172
|
break;
|
|
6200
7173
|
|
|
7174
|
+
case 'cline':
|
|
7175
|
+
// Slash forms: both the original ~/.claude/ (safety net) and the stage-time
|
|
7176
|
+
// converted ~/.cline/ (from convertClaudeToCliineMarkdown) → pathPrefix
|
|
7177
|
+
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
7178
|
+
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
|
7179
|
+
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
|
|
7180
|
+
content = content.replace(/~\/\.cline\//g, pathPrefix);
|
|
7181
|
+
content = content.replace(/\$HOME\/\.cline\//g, pathPrefix);
|
|
7182
|
+
// Bare forms (no trailing slash)
|
|
7183
|
+
content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
|
|
7184
|
+
content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
|
|
7185
|
+
content = content.replace(/~\/\.cline\b/g, normalizedPathPrefix);
|
|
7186
|
+
content = content.replace(/\$HOME\/\.cline\b/g, normalizedPathPrefix);
|
|
7187
|
+
content = processAttribution(content, getCommitAttribution(runtime));
|
|
7188
|
+
break;
|
|
7189
|
+
|
|
6201
7190
|
case 'cursor':
|
|
6202
7191
|
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
6203
7192
|
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
|
@@ -6240,7 +7229,14 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
6240
7229
|
content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
|
|
6241
7230
|
content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
|
|
6242
7231
|
content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
|
|
7232
|
+
// The codebuddy converter rewrites `.claude/` → `.codebuddy/` at stage
|
|
7233
|
+
// time, so `$HOME/.claude/...` arrives here as `$HOME/.codebuddy/...`.
|
|
7234
|
+
// Normalize BOTH the `~/` and `$HOME/` forms (slash + bare) to the install
|
|
7235
|
+
// target so `--config-dir`/local installs don't leak the default home.
|
|
6243
7236
|
content = content.replace(/~\/\.codebuddy\//g, pathPrefix);
|
|
7237
|
+
content = content.replace(/\$HOME\/\.codebuddy\//g, pathPrefix);
|
|
7238
|
+
content = content.replace(/~\/\.codebuddy\b/g, normalizedPathPrefix);
|
|
7239
|
+
content = content.replace(/\$HOME\/\.codebuddy\b/g, normalizedPathPrefix);
|
|
6244
7240
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
6245
7241
|
break;
|
|
6246
7242
|
|
|
@@ -6295,7 +7291,11 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
6295
7291
|
break;
|
|
6296
7292
|
|
|
6297
7293
|
default:
|
|
6298
|
-
// Unknown runtime — no rewrites
|
|
7294
|
+
// Unknown runtime — no rewrites.
|
|
7295
|
+
// OpenCode/Kilo are intentionally absent: their skills are written by
|
|
7296
|
+
// installOpencodeFamilySkills, which applies pathPrefix BEFORE the
|
|
7297
|
+
// command→skill conversion (mirroring copyFlattenedCommands) rather than
|
|
7298
|
+
// rewriting already-converted SKILL.md bodies. See #784.
|
|
6299
7299
|
break;
|
|
6300
7300
|
}
|
|
6301
7301
|
|
|
@@ -6558,8 +7558,14 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
6558
7558
|
|
|
6559
7559
|
for (const kind of layout.kinds) {
|
|
6560
7560
|
const staged = kind.stage(resolvedProfile);
|
|
7561
|
+
// stagedForCopy: the directory to copy from (may differ from staged if rewrites
|
|
7562
|
+
// produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
|
|
7563
|
+
let stagedForCopy = staged;
|
|
6561
7564
|
if (kind.kind === 'skills') {
|
|
6562
7565
|
applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix);
|
|
7566
|
+
} else if (kind.kind === 'commands') {
|
|
7567
|
+
// Returns a temp dir with rewritten content so source files are never mutated.
|
|
7568
|
+
stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix);
|
|
6563
7569
|
}
|
|
6564
7570
|
const dest = path.join(layout.configDir, kind.destSubpath);
|
|
6565
7571
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -6581,8 +7587,8 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
6581
7587
|
|
|
6582
7588
|
if (kind.prefix === '') {
|
|
6583
7589
|
// Hermes: wipes entire dest dir — preserve anything not in staged.
|
|
6584
|
-
const stagedNames = fs.existsSync(
|
|
6585
|
-
? new Set(fs.readdirSync(
|
|
7590
|
+
const stagedNames = fs.existsSync(stagedForCopy)
|
|
7591
|
+
? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true })
|
|
6586
7592
|
.filter(e => e.isDirectory()).map(e => e.name))
|
|
6587
7593
|
: new Set();
|
|
6588
7594
|
for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
|
|
@@ -6603,7 +7609,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
6603
7609
|
}
|
|
6604
7610
|
|
|
6605
7611
|
_removeGsdEntries(dest, kind);
|
|
6606
|
-
_copyStaged(
|
|
7612
|
+
_copyStaged(stagedForCopy, dest, kind);
|
|
6607
7613
|
|
|
6608
7614
|
// Restore user-owned dirs after the prune+copy
|
|
6609
7615
|
for (const [dirName, snap] of toPreserve) {
|
|
@@ -6613,11 +7619,92 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
6613
7619
|
// For non-skills kinds (commands, agents): no user content to preserve;
|
|
6614
7620
|
// just prune stale gsd-* entries and copy new ones.
|
|
6615
7621
|
_removeGsdEntries(dest, kind);
|
|
6616
|
-
_copyStaged(
|
|
7622
|
+
_copyStaged(stagedForCopy, dest, kind);
|
|
6617
7623
|
}
|
|
6618
7624
|
}
|
|
6619
7625
|
}
|
|
6620
7626
|
|
|
7627
|
+
/**
|
|
7628
|
+
* Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
|
|
7629
|
+
*
|
|
7630
|
+
* These runtimes do NOT go through installRuntimeArtifacts (their commands use a
|
|
7631
|
+
* bespoke flattened-command writer), so this writes ONLY the skills kind
|
|
7632
|
+
* alongside their existing command/ + agents/ surfaces. Uninstall is already
|
|
7633
|
+
* layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
|
|
7634
|
+
* skills/ dir is cleaned up automatically once the layout declares it.
|
|
7635
|
+
*
|
|
7636
|
+
* `rawCommandsDir` MUST be the SAME staged command directory the flattened
|
|
7637
|
+
* command writer consumes (the caller passes its `_stageSkills()` output) so the
|
|
7638
|
+
* command/ and skills/ surfaces always cover the identical, profile-resolved set
|
|
7639
|
+
* — including the `--minimal`/`--core-only` alias path, which stages differently
|
|
7640
|
+
* from a plain `--profile=core`.
|
|
7641
|
+
*
|
|
7642
|
+
* Mirrors copyFlattenedCommands exactly per file — pathPrefix rewrite →
|
|
7643
|
+
* attribution → command→skill conversion — guaranteeing command/ and skills/
|
|
7644
|
+
* bodies match byte-for-byte for global, --local, and --config-dir installs.
|
|
7645
|
+
* We deliberately do NOT use skillsKindEntry.stage(): that converts before any
|
|
7646
|
+
* pathPrefix is known, so its bodies would carry the converter's hardcoded
|
|
7647
|
+
* default config dir. (#784)
|
|
7648
|
+
*
|
|
7649
|
+
* @param {string} runtime - 'opencode' or 'kilo'
|
|
7650
|
+
* @param {string} targetDir - resolved runtime config directory
|
|
7651
|
+
* @param {string} rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
|
|
7652
|
+
* @param {string} pathPrefix - computed config-path prefix for body rewrites
|
|
7653
|
+
* @returns {number} number of gsd-* skill directories written
|
|
7654
|
+
*/
|
|
7655
|
+
function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix) {
|
|
7656
|
+
const layout = resolveRuntimeArtifactLayout(runtime, targetDir);
|
|
7657
|
+
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
|
7658
|
+
if (!skillsKindEntry) return 0;
|
|
7659
|
+
const rawDir = rawCommandsDir;
|
|
7660
|
+
if (!rawDir || !fs.existsSync(rawDir)) return 0;
|
|
7661
|
+
|
|
7662
|
+
const converter = runtime === 'kilo'
|
|
7663
|
+
? convertClaudeCommandToKiloSkill
|
|
7664
|
+
: convertClaudeCommandToOpencodeSkill;
|
|
7665
|
+
|
|
7666
|
+
const dest = path.join(targetDir, skillsKindEntry.destSubpath);
|
|
7667
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
7668
|
+
|
|
7669
|
+
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
|
|
7670
|
+
// gsd-dev-preferences is generated by the user (via generate-dev-preferences)
|
|
7671
|
+
// and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
|
|
7672
|
+
// would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts
|
|
7673
|
+
// (#2973).
|
|
7674
|
+
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
|
7675
|
+
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
|
|
7676
|
+
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
|
7677
|
+
const skillDir = path.join(dest, dirName);
|
|
7678
|
+
if (!fs.existsSync(skillDir)) continue;
|
|
7679
|
+
const snap = _snapshotDir(skillDir);
|
|
7680
|
+
if (snap.size > 0) toPreserve.set(dirName, snap);
|
|
7681
|
+
}
|
|
7682
|
+
|
|
7683
|
+
_removeGsdEntries(dest, skillsKindEntry);
|
|
7684
|
+
|
|
7685
|
+
let count = 0;
|
|
7686
|
+
for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
|
|
7687
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
7688
|
+
const stem = entry.name.slice(0, -3);
|
|
7689
|
+
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
|
7690
|
+
let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
|
|
7691
|
+
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
7692
|
+
content = processAttribution(content, getCommitAttribution(runtime));
|
|
7693
|
+
content = converter(content, skillName);
|
|
7694
|
+
const skillDir = path.join(dest, skillName);
|
|
7695
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
7696
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
|
7697
|
+
count++;
|
|
7698
|
+
}
|
|
7699
|
+
|
|
7700
|
+
// Restore user-owned dirs after the prune+copy.
|
|
7701
|
+
for (const [dirName, snap] of toPreserve) {
|
|
7702
|
+
_restoreDir(path.join(dest, dirName), snap);
|
|
7703
|
+
}
|
|
7704
|
+
|
|
7705
|
+
return count;
|
|
7706
|
+
}
|
|
7707
|
+
|
|
6621
7708
|
/**
|
|
6622
7709
|
* Layout-driven uninstall orchestrator.
|
|
6623
7710
|
* Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
|
|
@@ -6722,8 +7809,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
|
|
6722
7809
|
: convertClaudeToOpencodeFrontmatter(content);
|
|
6723
7810
|
fs.writeFileSync(destPath, content);
|
|
6724
7811
|
} else if (isGemini) {
|
|
6725
|
-
// Apply Gemini-specific Markdown transformations (slash commands, TOML)
|
|
6726
|
-
|
|
7812
|
+
// Apply Gemini-specific Markdown transformations (slash commands, TOML).
|
|
7813
|
+
// #778: thread the command name (file stem) so per-command TOML
|
|
7814
|
+
// enrichment (live-state injection) can target a specific command.
|
|
7815
|
+
const geminiCommandName = isCommand ? entry.name.replace(/\.md$/, '') : null;
|
|
7816
|
+
const processed = convertClaudeToGeminiMarkdown(content, { isCommand, commandName: geminiCommandName });
|
|
6727
7817
|
const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath;
|
|
6728
7818
|
fs.writeFileSync(finalPath, processed);
|
|
6729
7819
|
} else if (isCodex) {
|
|
@@ -6968,7 +8058,10 @@ const GSD_UNINSTALL_HOOKS = [
|
|
|
6968
8058
|
'gsd-statusline.js',
|
|
6969
8059
|
'gsd-check-update.js',
|
|
6970
8060
|
'gsd-check-update.cmd',
|
|
8061
|
+
'gsd-config-reload.js',
|
|
6971
8062
|
'gsd-context-monitor.js',
|
|
8063
|
+
'gsd-cursor-session-start.js',
|
|
8064
|
+
'gsd-cursor-post-tool.js',
|
|
6972
8065
|
'gsd-prompt-guard.js',
|
|
6973
8066
|
'gsd-read-guard.js',
|
|
6974
8067
|
'gsd-read-injection-scanner.js',
|
|
@@ -7002,10 +8095,14 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7002
8095
|
const isCodebuddy = runtime === 'codebuddy';
|
|
7003
8096
|
const dirName = getDirName(runtime);
|
|
7004
8097
|
|
|
7005
|
-
// Get the target directory based on runtime and install type
|
|
8098
|
+
// Get the target directory based on runtime and install type. Cline local
|
|
8099
|
+
// installs write to the project root (.clinerules/ lives at the root, not in
|
|
8100
|
+
// a .cline/ subdir), mirroring the install() path resolution (#787).
|
|
7006
8101
|
const targetDir = isGlobal
|
|
7007
|
-
?
|
|
7008
|
-
:
|
|
8102
|
+
? getGlobalConfigDir(runtime, explicitConfigDir)
|
|
8103
|
+
: runtime === 'cline'
|
|
8104
|
+
? process.cwd()
|
|
8105
|
+
: path.join(process.cwd(), dirName);
|
|
7009
8106
|
|
|
7010
8107
|
const locationLabel = isGlobal
|
|
7011
8108
|
? targetDir.replace(os.homedir(), '~')
|
|
@@ -7028,6 +8125,24 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7028
8125
|
|
|
7029
8126
|
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
|
|
7030
8127
|
|
|
8128
|
+
// #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot
|
|
8129
|
+
// installs, so its cleanup must run even when .github (targetDir) was already
|
|
8130
|
+
// removed — i.e. BEFORE the "target directory missing" early-return below.
|
|
8131
|
+
if (isCopilot && !isGlobal) {
|
|
8132
|
+
const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
|
|
8133
|
+
if (fs.existsSync(agentsMdPath)) {
|
|
8134
|
+
const content = fs.readFileSync(agentsMdPath, 'utf8');
|
|
8135
|
+
const cleaned = stripGsdFromCopilotInstructions(content);
|
|
8136
|
+
if (cleaned === null) {
|
|
8137
|
+
fs.unlinkSync(agentsMdPath);
|
|
8138
|
+
console.log(` ${green}✓${reset} Removed AGENTS.md (was GSD-only)`);
|
|
8139
|
+
} else if (cleaned !== content) {
|
|
8140
|
+
fs.writeFileSync(agentsMdPath, cleaned);
|
|
8141
|
+
console.log(` ${green}✓${reset} Cleaned GSD section from AGENTS.md`);
|
|
8142
|
+
}
|
|
8143
|
+
}
|
|
8144
|
+
}
|
|
8145
|
+
|
|
7031
8146
|
// Check if target directory exists
|
|
7032
8147
|
if (!fs.existsSync(targetDir)) {
|
|
7033
8148
|
console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
|
|
@@ -7087,6 +8202,15 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7087
8202
|
removedCount++;
|
|
7088
8203
|
console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
|
|
7089
8204
|
}
|
|
8205
|
+
|
|
8206
|
+
// #772: remove new Codex hook event registrations added by this enhancement.
|
|
8207
|
+
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
|
|
8208
|
+
const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
|
|
8209
|
+
if (eventCleanup.changed) {
|
|
8210
|
+
removedCount++;
|
|
8211
|
+
console.log(` ${green}✓${reset} Removed managed Codex ${eventName} hook from hooks.json`);
|
|
8212
|
+
}
|
|
8213
|
+
}
|
|
7090
8214
|
}
|
|
7091
8215
|
|
|
7092
8216
|
// 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
|
|
@@ -7105,6 +8229,99 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7105
8229
|
console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`);
|
|
7106
8230
|
}
|
|
7107
8231
|
}
|
|
8232
|
+
|
|
8233
|
+
// #786: remove the GSD-managed Copilot lifecycle hook config and prune the
|
|
8234
|
+
// hooks dir if we left it empty.
|
|
8235
|
+
const hookPath = path.join(targetDir, 'hooks', GSD_COPILOT_HOOK_FILE);
|
|
8236
|
+
if (fs.existsSync(hookPath)) {
|
|
8237
|
+
fs.unlinkSync(hookPath);
|
|
8238
|
+
removedCount++;
|
|
8239
|
+
console.log(` ${green}✓${reset} Removed Copilot lifecycle hook (${GSD_COPILOT_HOOK_FILE})`);
|
|
8240
|
+
try {
|
|
8241
|
+
const hooksDir = path.join(targetDir, 'hooks');
|
|
8242
|
+
if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
|
|
8243
|
+
fs.rmdirSync(hooksDir);
|
|
8244
|
+
}
|
|
8245
|
+
} catch { /* non-fatal: leave a non-empty/locked hooks dir in place */ }
|
|
8246
|
+
}
|
|
8247
|
+
// Note: AGENTS.md (repo root) is cleaned earlier, before the targetDir
|
|
8248
|
+
// existence early-return, since it lives outside targetDir (#786).
|
|
8249
|
+
}
|
|
8250
|
+
|
|
8251
|
+
// 1b-cline. Non-layout Cline side-effects (issue #787): remove the
|
|
8252
|
+
// directory-form rules + PreToolUse hook, and strip the GSD block from the
|
|
8253
|
+
// global cross-tool ~/.agents/AGENTS.md target.
|
|
8254
|
+
if (runtime === 'cline') {
|
|
8255
|
+
const clinerulesDir = path.join(targetDir, '.clinerules');
|
|
8256
|
+
for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) {
|
|
8257
|
+
const p = path.join(clinerulesDir, rel);
|
|
8258
|
+
try {
|
|
8259
|
+
if (fs.existsSync(p)) {
|
|
8260
|
+
fs.unlinkSync(p);
|
|
8261
|
+
removedCount++;
|
|
8262
|
+
}
|
|
8263
|
+
} catch { /* best-effort */ }
|
|
8264
|
+
}
|
|
8265
|
+
// Also remove a legacy single-file .clinerules left by pre-#787 installs.
|
|
8266
|
+
try {
|
|
8267
|
+
if (fs.existsSync(clinerulesDir) && fs.statSync(clinerulesDir).isFile()) {
|
|
8268
|
+
fs.unlinkSync(clinerulesDir);
|
|
8269
|
+
removedCount++;
|
|
8270
|
+
}
|
|
8271
|
+
} catch { /* best-effort */ }
|
|
8272
|
+
// Prune now-empty GSD-created directories (leave any user-added rule files).
|
|
8273
|
+
for (const dir of [path.join(clinerulesDir, 'hooks'), clinerulesDir]) {
|
|
8274
|
+
try {
|
|
8275
|
+
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) {
|
|
8276
|
+
fs.rmdirSync(dir);
|
|
8277
|
+
}
|
|
8278
|
+
} catch { /* best-effort */ }
|
|
8279
|
+
}
|
|
8280
|
+
if (isGlobal) {
|
|
8281
|
+
const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
|
|
8282
|
+
try {
|
|
8283
|
+
if (fs.existsSync(agentsPath)) {
|
|
8284
|
+
const content = fs.readFileSync(agentsPath, 'utf8');
|
|
8285
|
+
const cleaned = stripGsdFromAgentsMd(content);
|
|
8286
|
+
if (cleaned === null) {
|
|
8287
|
+
fs.unlinkSync(agentsPath);
|
|
8288
|
+
removedCount++;
|
|
8289
|
+
console.log(` ${green}✓${reset} Removed ~/.agents/AGENTS.md (was GSD-only)`);
|
|
8290
|
+
} else if (cleaned !== content) {
|
|
8291
|
+
fs.writeFileSync(agentsPath, cleaned);
|
|
8292
|
+
removedCount++;
|
|
8293
|
+
console.log(` ${green}✓${reset} Cleaned GSD section from ~/.agents/AGENTS.md`);
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
8296
|
+
} catch { /* best-effort */ }
|
|
8297
|
+
}
|
|
8298
|
+
}
|
|
8299
|
+
|
|
8300
|
+
// 1b-cursor. Non-layout Cursor side-effects (issue #777): remove GSD-managed
|
|
8301
|
+
// hook entries from hooks.json and clean up the managed hook scripts.
|
|
8302
|
+
if (isCursor) {
|
|
8303
|
+
const hooksJsonCleanup = removeCursorHooksJson(targetDir);
|
|
8304
|
+
if (hooksJsonCleanup.changed) {
|
|
8305
|
+
removedCount++;
|
|
8306
|
+
console.log(` ${green}✓${reset} Removed GSD-managed Cursor hooks from hooks.json`);
|
|
8307
|
+
}
|
|
8308
|
+
// Remove the managed hook scripts (session-start + post-tool).
|
|
8309
|
+
const hooksDir = path.join(targetDir, 'hooks');
|
|
8310
|
+
for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) {
|
|
8311
|
+
const p = path.join(hooksDir, script);
|
|
8312
|
+
try {
|
|
8313
|
+
if (fs.existsSync(p)) {
|
|
8314
|
+
fs.unlinkSync(p);
|
|
8315
|
+
removedCount++;
|
|
8316
|
+
}
|
|
8317
|
+
} catch { /* best-effort */ }
|
|
8318
|
+
}
|
|
8319
|
+
// Prune hooks/ if empty.
|
|
8320
|
+
try {
|
|
8321
|
+
if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
|
|
8322
|
+
fs.rmdirSync(hooksDir);
|
|
8323
|
+
}
|
|
8324
|
+
} catch { /* best-effort */ }
|
|
7108
8325
|
}
|
|
7109
8326
|
|
|
7110
8327
|
// 1c. Claude local: remove commands/gsd/ (primary local install location).
|
|
@@ -7305,8 +8522,14 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7305
8522
|
}
|
|
7306
8523
|
|
|
7307
8524
|
// Remove GSD hooks from settings — per-hook granularity to preserve
|
|
7308
|
-
// user hooks that share an entry with a GSD hook (#1755 followup)
|
|
7309
|
-
|
|
8525
|
+
// user hooks that share an entry with a GSD hook (#1755 followup).
|
|
8526
|
+
// Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop,
|
|
8527
|
+
// PreCompact, also registered for Claude in #770), the 3 Gemini-only
|
|
8528
|
+
// events added in #776 (BeforeAgent, AfterAgent, BeforeModel), and the
|
|
8529
|
+
// Claude-only FileChanged event added in #770 — safe to iterate for all
|
|
8530
|
+
// runtimes; installs that don't register these events simply find no
|
|
8531
|
+
// entries and skip.
|
|
8532
|
+
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'FileChanged']) {
|
|
7310
8533
|
if (settings.hooks && settings.hooks[eventName]) {
|
|
7311
8534
|
const before = JSON.stringify(settings.hooks[eventName]);
|
|
7312
8535
|
settings.hooks[eventName] = settings.hooks[eventName]
|
|
@@ -7339,6 +8562,37 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7339
8562
|
delete settings.hooks;
|
|
7340
8563
|
}
|
|
7341
8564
|
|
|
8565
|
+
// #768 — Remove GSD-owned Claude permissions from settings.json.
|
|
8566
|
+
// Applies only to Claude uninstalls. Filter only the exact GSD-owned entries
|
|
8567
|
+
// to preserve any user-added allow/deny entries.
|
|
8568
|
+
// Uses a local flag to avoid the shared `settingsModified` producing a false
|
|
8569
|
+
// "Removed GSD permissions" message when only hooks/statusline changed.
|
|
8570
|
+
if (runtime === 'claude' && settings.permissions) {
|
|
8571
|
+
let permissionsModified = false;
|
|
8572
|
+
if (Array.isArray(settings.permissions.allow)) {
|
|
8573
|
+
const before = settings.permissions.allow.length;
|
|
8574
|
+
settings.permissions.allow = settings.permissions.allow.filter(
|
|
8575
|
+
(e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e)
|
|
8576
|
+
);
|
|
8577
|
+
if (settings.permissions.allow.length !== before) {
|
|
8578
|
+
permissionsModified = true;
|
|
8579
|
+
}
|
|
8580
|
+
}
|
|
8581
|
+
if (Array.isArray(settings.permissions.deny)) {
|
|
8582
|
+
const before = settings.permissions.deny.length;
|
|
8583
|
+
settings.permissions.deny = settings.permissions.deny.filter(
|
|
8584
|
+
(e) => !GSD_CLAUDE_DENY_PERMISSIONS.includes(e)
|
|
8585
|
+
);
|
|
8586
|
+
if (settings.permissions.deny.length !== before) {
|
|
8587
|
+
permissionsModified = true;
|
|
8588
|
+
}
|
|
8589
|
+
}
|
|
8590
|
+
if (permissionsModified) {
|
|
8591
|
+
settingsModified = true;
|
|
8592
|
+
console.log(` ${green}✓${reset} Removed GSD permissions from settings.json`);
|
|
8593
|
+
}
|
|
8594
|
+
}
|
|
8595
|
+
|
|
7342
8596
|
if (settingsModified) {
|
|
7343
8597
|
writeSettings(settingsPath, settings);
|
|
7344
8598
|
removedCount++;
|
|
@@ -7517,7 +8771,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
|
|
|
7517
8771
|
// For local installs, use ./.opencode/
|
|
7518
8772
|
// For global installs, use ~/.config/opencode/
|
|
7519
8773
|
const opencodeConfigDir = configDir || (isGlobal
|
|
7520
|
-
?
|
|
8774
|
+
? getGlobalConfigDir('opencode', explicitConfigDir)
|
|
7521
8775
|
: path.join(process.cwd(), '.opencode'));
|
|
7522
8776
|
// Ensure config directory exists
|
|
7523
8777
|
fs.mkdirSync(opencodeConfigDir, { recursive: true });
|
|
@@ -7597,7 +8851,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
|
|
|
7597
8851
|
// For local installs, use ./.kilo/
|
|
7598
8852
|
// For global installs, use ~/.config/kilo/
|
|
7599
8853
|
const kiloConfigDir = configDir || (isGlobal
|
|
7600
|
-
?
|
|
8854
|
+
? getGlobalConfigDir('kilo', explicitConfigDir)
|
|
7601
8855
|
: path.join(process.cwd(), '.kilo'));
|
|
7602
8856
|
// Ensure config directory exists
|
|
7603
8857
|
fs.mkdirSync(kiloConfigDir, { recursive: true });
|
|
@@ -7871,11 +9125,15 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
|
|
7871
9125
|
}
|
|
7872
9126
|
}
|
|
7873
9127
|
}
|
|
7874
|
-
// Track
|
|
9128
|
+
// Track Cline directory-form artifacts in the manifest (issue #787): the
|
|
9129
|
+
// rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its
|
|
9130
|
+
// marker block, not the per-configDir manifest, since it lives outside it.)
|
|
7875
9131
|
if (isCline) {
|
|
7876
|
-
const
|
|
7877
|
-
|
|
7878
|
-
|
|
9132
|
+
for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) {
|
|
9133
|
+
const dest = path.join(configDir, rel);
|
|
9134
|
+
if (fs.existsSync(dest)) {
|
|
9135
|
+
manifest.files[rel] = fileHash(dest);
|
|
9136
|
+
}
|
|
7879
9137
|
}
|
|
7880
9138
|
}
|
|
7881
9139
|
|
|
@@ -8240,6 +9498,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8240
9498
|
const isHermes = runtime === 'hermes';
|
|
8241
9499
|
const isCodebuddy = runtime === 'codebuddy';
|
|
8242
9500
|
const isCline = runtime === 'cline';
|
|
9501
|
+
const configIntent = resolveRuntimeConfigIntent(runtime);
|
|
8243
9502
|
const dirName = getDirName(runtime);
|
|
8244
9503
|
const src = path.join(__dirname, '..');
|
|
8245
9504
|
|
|
@@ -8277,7 +9536,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8277
9536
|
// Cline local installs write to the project root (like Claude Code) — .clinerules
|
|
8278
9537
|
// lives at the root, not inside a .cline/ subdirectory.
|
|
8279
9538
|
const targetDir = isGlobal
|
|
8280
|
-
?
|
|
9539
|
+
? getGlobalConfigDir(runtime, explicitConfigDir)
|
|
8281
9540
|
: isCline
|
|
8282
9541
|
? process.cwd()
|
|
8283
9542
|
: path.join(process.cwd(), dirName);
|
|
@@ -8575,6 +9834,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8575
9834
|
// agentsSrc is declared here (let, not const) because installCodexConfig() inside the
|
|
8576
9835
|
// Codex config block below also references it, and that block is outside the try scope.
|
|
8577
9836
|
let agentsSrc = path.join(src, 'agents');
|
|
9837
|
+
// Capture upgrade signal BEFORE files are written (#683). Must be declared at function
|
|
9838
|
+
// scope (outside the try block below) so it is accessible in the settings section later.
|
|
9839
|
+
// Absent VERSION = fresh install; present VERSION = upgrade/re-install.
|
|
9840
|
+
const priorInstallExisted = fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'));
|
|
8578
9841
|
try {
|
|
8579
9842
|
installerMigrationResult = runInstallerMigrations({
|
|
8580
9843
|
configDir: targetDir,
|
|
@@ -8642,7 +9905,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8642
9905
|
//
|
|
8643
9906
|
// Non-layout side-effects preserved inline:
|
|
8644
9907
|
// Hermes: writeHermesCategoryDescription (not a layout kind)
|
|
8645
|
-
// Cline:
|
|
9908
|
+
// Cline global: skills emitted via layout; .clinerules still written below (#782)
|
|
9909
|
+
// Cline local: no skills (only .clinerules) — falls through to cline-rules surface
|
|
8646
9910
|
// Gemini: conflict-detection logic (not expressible in layout)
|
|
8647
9911
|
// OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
|
|
8648
9912
|
// Claude local: copyWithPathReplacement + stale-skills cleanup
|
|
@@ -8650,15 +9914,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8650
9914
|
// Layout-driven path for all skills-based runtimes (full and minimal modes).
|
|
8651
9915
|
// applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts)
|
|
8652
9916
|
// handles per-runtime path + branding rewrites, including Qwen/Hermes.
|
|
9917
|
+
// Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782).
|
|
8653
9918
|
const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
|
|
8654
9919
|
isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
|
|
8655
|
-
(runtime === 'claude' && isGlobal)
|
|
9920
|
+
(runtime === 'claude' && isGlobal) ||
|
|
9921
|
+
(isCline && isGlobal);
|
|
8656
9922
|
|
|
8657
9923
|
if (_isSkillsRuntime) {
|
|
8658
9924
|
// Layout-driven install for skills-based runtimes (full and minimal modes)
|
|
8659
9925
|
const scope = isGlobal ? 'global' : 'local';
|
|
8660
9926
|
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
|
|
8661
9927
|
|
|
9928
|
+
// #774 — Codex only: write agents/openai.yaml TUI chip metadata alongside each
|
|
9929
|
+
// installed skill so the /skills popup shows name + description for each gsd-* skill.
|
|
9930
|
+
// The SkillMetadataFile is loaded by codex-rs/core-skills/src/loader.rs from
|
|
9931
|
+
// <skill-dir>/agents/openai.yaml; absence is silently tolerated (fails open).
|
|
9932
|
+
// We parse the SKILL.md frontmatter to extract short-description already emitted
|
|
9933
|
+
// by convertClaudeCommandToCodexSkill and use it as the TUI chip description.
|
|
9934
|
+
if (isCodex) {
|
|
9935
|
+
writeCodexSkillMetadataFiles(path.join(targetDir, 'skills'));
|
|
9936
|
+
}
|
|
9937
|
+
|
|
8662
9938
|
// Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
|
|
8663
9939
|
if (isHermes) {
|
|
8664
9940
|
writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
|
|
@@ -8692,6 +9968,53 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8692
9968
|
} else {
|
|
8693
9969
|
failures.push('skills/gsd-*');
|
|
8694
9970
|
}
|
|
9971
|
+
// Augment: also verify commands/ (emitted alongside skills/)
|
|
9972
|
+
if (isAugment) {
|
|
9973
|
+
const commandsDir = path.join(targetDir, 'commands');
|
|
9974
|
+
if (fs.existsSync(commandsDir)) {
|
|
9975
|
+
const cmdCount = fs.readdirSync(commandsDir)
|
|
9976
|
+
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
|
|
9977
|
+
if (cmdCount > 0) {
|
|
9978
|
+
console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/`);
|
|
9979
|
+
} else {
|
|
9980
|
+
failures.push('commands/gsd-*');
|
|
9981
|
+
}
|
|
9982
|
+
} else {
|
|
9983
|
+
failures.push('commands/gsd-*');
|
|
9984
|
+
}
|
|
9985
|
+
}
|
|
9986
|
+
|
|
9987
|
+
// Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands)
|
|
9988
|
+
if (isCursor) {
|
|
9989
|
+
const commandsDir = path.join(targetDir, 'commands');
|
|
9990
|
+
if (fs.existsSync(commandsDir)) {
|
|
9991
|
+
const cmdCount = fs.readdirSync(commandsDir)
|
|
9992
|
+
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
|
|
9993
|
+
if (cmdCount > 0) {
|
|
9994
|
+
console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`);
|
|
9995
|
+
} else {
|
|
9996
|
+
failures.push('commands/gsd-*');
|
|
9997
|
+
}
|
|
9998
|
+
} else {
|
|
9999
|
+
failures.push('commands/gsd-*');
|
|
10000
|
+
}
|
|
10001
|
+
}
|
|
10002
|
+
|
|
10003
|
+
// CodeBuddy only: also report the commands/ output (#789 — slash commands)
|
|
10004
|
+
if (isCodebuddy) {
|
|
10005
|
+
const commandsDir = path.join(targetDir, 'commands');
|
|
10006
|
+
if (fs.existsSync(commandsDir)) {
|
|
10007
|
+
const cmdCount = fs.readdirSync(commandsDir)
|
|
10008
|
+
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
|
|
10009
|
+
if (cmdCount > 0) {
|
|
10010
|
+
console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`);
|
|
10011
|
+
} else {
|
|
10012
|
+
failures.push('commands/gsd-*');
|
|
10013
|
+
}
|
|
10014
|
+
} else {
|
|
10015
|
+
failures.push('commands/gsd-*');
|
|
10016
|
+
}
|
|
10017
|
+
}
|
|
8695
10018
|
}
|
|
8696
10019
|
} else if (isOpencode || isKilo) {
|
|
8697
10020
|
// OpenCode/Kilo: flat structure in command/ directory
|
|
@@ -8707,9 +10030,21 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8707
10030
|
} else {
|
|
8708
10031
|
failures.push('command/gsd-*');
|
|
8709
10032
|
}
|
|
10033
|
+
|
|
10034
|
+
// Also emit OpenCode-family skills (skills/<name>/SKILL.md). OpenCode and
|
|
10035
|
+
// Kilo support native, on-demand skills in addition to flat commands — see
|
|
10036
|
+
// resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
|
|
10037
|
+
// the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
|
|
10038
|
+
const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix);
|
|
10039
|
+
if (_skillCount > 0) {
|
|
10040
|
+
console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
|
|
10041
|
+
} else {
|
|
10042
|
+
failures.push('skills/gsd-*');
|
|
10043
|
+
}
|
|
8710
10044
|
} else if (isCline) {
|
|
8711
|
-
// Cline
|
|
8712
|
-
// No skills/commands directory needed
|
|
10045
|
+
// Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
|
|
10046
|
+
// No skills/commands directory needed for local installs.
|
|
10047
|
+
// Global installs are handled above by _isSkillsRuntime (#782).
|
|
8713
10048
|
console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
|
|
8714
10049
|
} else if (isGemini) {
|
|
8715
10050
|
// #3037: when running --local --gemini and a GSD-managed user-scope
|
|
@@ -9089,8 +10424,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9089
10424
|
}
|
|
9090
10425
|
|
|
9091
10426
|
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
|
|
9092
|
-
// Codex/Copilot/Cursor/Windsurf/Trae/Cline
|
|
9093
|
-
//
|
|
10427
|
+
// Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
|
|
10428
|
+
// (Cursor uses standalone .js hook scripts registered via hooks.json; Codex uses
|
|
10429
|
+
// hooks.json directly; the others skip hooks entirely), so they must not receive
|
|
10430
|
+
// the hooks/lib/ helpers — otherwise the Codex comment downstream
|
|
9094
10431
|
// ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
|
|
9095
10432
|
const hooksLibSrc = path.join(src, 'hooks', 'lib');
|
|
9096
10433
|
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
|
|
@@ -9196,7 +10533,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9196
10533
|
throw _earlyInstallErr;
|
|
9197
10534
|
}
|
|
9198
10535
|
|
|
9199
|
-
if (
|
|
10536
|
+
if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
|
|
9200
10537
|
// Capture pre-install snapshots before ANY GSD mutation
|
|
9201
10538
|
// (#2760 fix 3). On post-write schema-validation failure OR any throw
|
|
9202
10539
|
// during the mutation sequence (write failure, merge throw, etc.) we
|
|
@@ -9378,10 +10715,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9378
10715
|
}
|
|
9379
10716
|
|
|
9380
10717
|
// Copy only the hook files that Codex actually registers via its hook configuration (#2153).
|
|
9381
|
-
//
|
|
10718
|
+
// #772: added gsd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events.
|
|
9382
10719
|
// We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
|
|
9383
10720
|
// in this change (graphify auto-update support for Codex is out of scope for #3579).
|
|
9384
|
-
const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js'];
|
|
10721
|
+
const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js', 'gsd-context-monitor.js'];
|
|
9385
10722
|
const codexHooksSrc = path.join(src, 'hooks', 'dist');
|
|
9386
10723
|
if (fs.existsSync(codexHooksSrc)) {
|
|
9387
10724
|
const codexHooksDest = path.join(targetDir, 'hooks');
|
|
@@ -9511,6 +10848,39 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9511
10848
|
console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`);
|
|
9512
10849
|
}
|
|
9513
10850
|
}
|
|
10851
|
+
|
|
10852
|
+
// ── Codex extended hook events (#772) ────────────────────────────────
|
|
10853
|
+
// Codex CLI stabilised a full hook-event set in rust-v0.137.0. Register
|
|
10854
|
+
// three new high-value lifecycle events — all routed through
|
|
10855
|
+
// gsd-context-monitor.js so context-headroom warnings surface at:
|
|
10856
|
+
// SubagentStart — subagent session open (environment / agent-name aware)
|
|
10857
|
+
// Stop — model stop / session final-response moment
|
|
10858
|
+
// PostToolUse — after each tool invocation (mirrors Claude baseline)
|
|
10859
|
+
//
|
|
10860
|
+
// Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
|
|
10861
|
+
// tool_name is Write|Edit (PreToolUse payload shape), so it would be a
|
|
10862
|
+
// silent no-op for the UserPromptSubmit payload. Registration deferred
|
|
10863
|
+
// to a follow-on issue.
|
|
10864
|
+
//
|
|
10865
|
+
// Guard: only register when the context-monitor file exists and the node
|
|
10866
|
+
// runner is available — same guards as the SessionStart path above.
|
|
10867
|
+
const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
|
|
10868
|
+
if (codexNodeRunner && fs.existsSync(contextMonitorFile)) {
|
|
10869
|
+
for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) {
|
|
10870
|
+
const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, {
|
|
10871
|
+
absoluteRunner: codexNodeRunner,
|
|
10872
|
+
platform: process.platform,
|
|
10873
|
+
});
|
|
10874
|
+
if (eventWrite.wrote) {
|
|
10875
|
+
console.log(` ${green}✓${reset} Configured Codex hooks (${codexEvent} via hooks.json)`);
|
|
10876
|
+
} else if (eventWrite.changed) {
|
|
10877
|
+
console.log(` ${green}✓${reset} Verified Codex hooks (${codexEvent} via hooks.json)`);
|
|
10878
|
+
}
|
|
10879
|
+
}
|
|
10880
|
+
} else if (!codexNodeRunner) {
|
|
10881
|
+
console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`);
|
|
10882
|
+
}
|
|
10883
|
+
// ── end Codex extended hook events ────────────────────────────────────
|
|
9514
10884
|
}
|
|
9515
10885
|
} catch (e) {
|
|
9516
10886
|
// #2760 — schema-validation and write failures must be loud and fatal
|
|
@@ -9542,7 +10912,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9542
10912
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9543
10913
|
}
|
|
9544
10914
|
|
|
9545
|
-
if (
|
|
10915
|
+
if (configIntent.installSurface === 'copilot-instructions') {
|
|
9546
10916
|
// Generate copilot-instructions.md
|
|
9547
10917
|
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
|
|
9548
10918
|
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
|
|
@@ -9550,47 +10920,57 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9550
10920
|
const template = fs.readFileSync(templatePath, 'utf8');
|
|
9551
10921
|
mergeCopilotInstructions(instructionsPath, template);
|
|
9552
10922
|
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9558
|
-
|
|
9559
|
-
|
|
9560
|
-
|
|
10923
|
+
// #786: also emit AGENTS.md, which Copilot CLI reads as primary
|
|
10924
|
+
// instructions from the repository root. AGENTS.md is a repo-root concept
|
|
10925
|
+
// (no documented user-scope home), so emit it only for local installs;
|
|
10926
|
+
// global scope is already covered by ~/.copilot/copilot-instructions.md.
|
|
10927
|
+
if (!isGlobal) {
|
|
10928
|
+
const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
|
|
10929
|
+
mergeCopilotInstructions(agentsMdPath, template);
|
|
10930
|
+
console.log(` ${green}✓${reset} Generated AGENTS.md`);
|
|
10931
|
+
}
|
|
10932
|
+
}
|
|
10933
|
+
// #786: emit a self-contained Copilot lifecycle hook (sessionStart). Copilot
|
|
10934
|
+
// command hooks run inline bash/powershell, so this needs no separate hook
|
|
10935
|
+
// script and cannot dangle. Repo scope → .github/hooks/, user → ~/.copilot/hooks/.
|
|
10936
|
+
// The hook is a required install artifact, so a write failure is fatal (it
|
|
10937
|
+
// propagates) rather than silently producing a "successful" install missing
|
|
10938
|
+
// the feature.
|
|
10939
|
+
writeCopilotHookConfig(targetDir);
|
|
10940
|
+
console.log(` ${green}✓${reset} Configured Copilot lifecycle hook (sessionStart)`);
|
|
9561
10941
|
persistActiveProfileMarker();
|
|
9562
10942
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9563
10943
|
}
|
|
9564
10944
|
|
|
9565
|
-
if (
|
|
9566
|
-
//
|
|
10945
|
+
if (configIntent.installSurface === 'cursor-hooks-json') {
|
|
10946
|
+
// #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
|
|
10947
|
+
// Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
|
|
10948
|
+
const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
|
|
10949
|
+
if (cursorHookResult.changed) {
|
|
10950
|
+
console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse)`);
|
|
10951
|
+
} else {
|
|
10952
|
+
console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
|
|
10953
|
+
}
|
|
10954
|
+
// Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
|
|
10955
|
+
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
|
|
9567
10956
|
persistActiveProfileMarker();
|
|
9568
10957
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9569
10958
|
}
|
|
9570
10959
|
|
|
9571
|
-
if (
|
|
9572
|
-
// Trae
|
|
10960
|
+
if (configIntent.installSurface === 'profile-marker-only') {
|
|
10961
|
+
// Windsurf/Trae use skills — no config.toml, no settings.json hooks needed
|
|
9573
10962
|
persistActiveProfileMarker();
|
|
9574
10963
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9575
10964
|
}
|
|
9576
10965
|
|
|
9577
|
-
if (
|
|
9578
|
-
// Cline uses
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
'- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
|
|
9586
|
-
'- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
|
|
9587
|
-
'- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
|
|
9588
|
-
'- Do not apply GSD workflows unless the user explicitly asks for them.',
|
|
9589
|
-
'- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
|
|
9590
|
-
' step to the user using Cline\'s ask_user tool after completing it.',
|
|
9591
|
-
].join('\n') + '\n';
|
|
9592
|
-
fs.writeFileSync(clinerulesDest, clinerules);
|
|
9593
|
-
console.log(` ${green}✓${reset} Wrote .clinerules`);
|
|
10966
|
+
if (configIntent.installSurface === 'cline-rules') {
|
|
10967
|
+
// Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
|
|
10968
|
+
// at .clinerules/gsd.md and a PreToolUse lifecycle hook at
|
|
10969
|
+
// .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
|
|
10970
|
+
writeClineArtifacts(targetDir, isGlobal);
|
|
10971
|
+
// Re-run the manifest pass: these artifacts are written *after* the earlier
|
|
10972
|
+
// writeManifest() call, so a second pass is needed to hash-track them.
|
|
10973
|
+
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
|
|
9594
10974
|
persistActiveProfileMarker();
|
|
9595
10975
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9596
10976
|
}
|
|
@@ -9741,6 +11121,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9741
11121
|
const readInjectionScannerCommand = isGlobal
|
|
9742
11122
|
? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts)
|
|
9743
11123
|
: localCmd('gsd-read-injection-scanner.js');
|
|
11124
|
+
const configReloadCommand = isGlobal
|
|
11125
|
+
? buildHookCommand(targetDir, 'gsd-config-reload.js', hookOpts)
|
|
11126
|
+
: localCmd('gsd-config-reload.js');
|
|
9744
11127
|
|
|
9745
11128
|
// #3002 CR: when resolveNodeRunner() returns null, every dependent JS-hook
|
|
9746
11129
|
// command is null too. Emit one warning here so the operator sees the cause
|
|
@@ -10094,7 +11477,155 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10094
11477
|
} else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
|
|
10095
11478
|
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
|
|
10096
11479
|
}
|
|
11480
|
+
|
|
11481
|
+
// ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
|
|
11482
|
+
// Claude Code (since #770) and Qwen Code (since #788) both support these
|
|
11483
|
+
// three lifecycle events. Wire gsd-context-monitor so agents get context-
|
|
11484
|
+
// headroom warnings at subagent completion, model stop, and pre-compaction
|
|
11485
|
+
// (the most critical moment to surface headroom info).
|
|
11486
|
+
//
|
|
11487
|
+
// SubagentStop — subagent lifecycle completion (context headroom tracking)
|
|
11488
|
+
// Stop — model stop / final-response moment (context headroom)
|
|
11489
|
+
// PreCompact — fires before conversation compaction (most critical
|
|
11490
|
+
// moment to surface context headroom warnings)
|
|
11491
|
+
//
|
|
11492
|
+
// Note: UserPromptSubmit is NOT wired here. That event carries the raw
|
|
11493
|
+
// user prompt text, not a tool invocation, so gsd-prompt-guard (which
|
|
11494
|
+
// exits unless tool_name is Write/Edit) would be a silent no-op. A
|
|
11495
|
+
// dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
|
|
11496
|
+
if (isQwen || runtime === 'claude') {
|
|
11497
|
+
const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code';
|
|
11498
|
+
// SubagentStop, Stop, PreCompact — route through the context monitor.
|
|
11499
|
+
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
|
11500
|
+
if (!settings.hooks[event]) {
|
|
11501
|
+
settings.hooks[event] = [];
|
|
11502
|
+
}
|
|
11503
|
+
const alreadyHasContextMonitor = settings.hooks[event].some(entry =>
|
|
11504
|
+
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
|
|
11505
|
+
);
|
|
11506
|
+
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
11507
|
+
settings.hooks[event].push({
|
|
11508
|
+
hooks: [
|
|
11509
|
+
{
|
|
11510
|
+
type: 'command',
|
|
11511
|
+
command: contextMonitorCommand,
|
|
11512
|
+
timeout: 10
|
|
11513
|
+
}
|
|
11514
|
+
]
|
|
11515
|
+
});
|
|
11516
|
+
console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`);
|
|
11517
|
+
} else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
|
|
11518
|
+
console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`);
|
|
11519
|
+
}
|
|
11520
|
+
}
|
|
11521
|
+
}
|
|
11522
|
+
// ── end SubagentStop / Stop / PreCompact events ────────────────────────────
|
|
11523
|
+
|
|
11524
|
+
// ── Gemini-only extended hook events (#776) ───────────────────────────────
|
|
11525
|
+
// Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
|
|
11526
|
+
// gsd previously did not register. Three high-value events are added here:
|
|
11527
|
+
//
|
|
11528
|
+
// BeforeAgent — fires after user submits a prompt, before the agent
|
|
11529
|
+
// plans. Wire gsd-context-monitor for context headroom
|
|
11530
|
+
// awareness at prompt time.
|
|
11531
|
+
// AfterAgent — fires once per turn after the model generates its final
|
|
11532
|
+
// response. Wire gsd-context-monitor to track headroom
|
|
11533
|
+
// after each agent turn completes.
|
|
11534
|
+
// BeforeModel — fires before each LLM call (per-turn, not per-session).
|
|
11535
|
+
// Wire gsd-context-monitor for per-turn context injection
|
|
11536
|
+
// — more precise than session-start-only injection.
|
|
11537
|
+
//
|
|
11538
|
+
// All three reuse gsd-context-monitor.js — no new hook files needed.
|
|
11539
|
+
// The `decision:"deny"` retry capability of AfterAgent is intentionally
|
|
11540
|
+
// left to the hook script to implement when triggered (gsd-context-monitor
|
|
11541
|
+
// exits 0 / advisory-only today; an active quality gate is a follow-on).
|
|
11542
|
+
//
|
|
11543
|
+
// Note: BeforeToolSelection is NOT wired. That event does not map to a
|
|
11544
|
+
// gsd hook use case at this time; deferred to a follow-on issue.
|
|
11545
|
+
//
|
|
11546
|
+
// Guard: isGemini is defined at the top of install() (line ~8696).
|
|
11547
|
+
if (isGemini) {
|
|
11548
|
+
for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
|
11549
|
+
if (!Array.isArray(settings.hooks[geminiEvent])) {
|
|
11550
|
+
settings.hooks[geminiEvent] = [];
|
|
11551
|
+
}
|
|
11552
|
+
const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry =>
|
|
11553
|
+
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
|
|
11554
|
+
);
|
|
11555
|
+
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
11556
|
+
settings.hooks[geminiEvent].push({
|
|
11557
|
+
hooks: [
|
|
11558
|
+
{
|
|
11559
|
+
type: 'command',
|
|
11560
|
+
command: contextMonitorCommand,
|
|
11561
|
+
timeout: 10
|
|
11562
|
+
}
|
|
11563
|
+
]
|
|
11564
|
+
});
|
|
11565
|
+
console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
|
|
11566
|
+
} else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
|
|
11567
|
+
console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
|
|
11568
|
+
}
|
|
11569
|
+
}
|
|
11570
|
+
}
|
|
11571
|
+
// ── end Gemini-only extended hook events ──────────────────────────────────
|
|
11572
|
+
|
|
11573
|
+
// ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─
|
|
11574
|
+
// Claude Code fires FileChanged when a watched file changes on disk. Wire
|
|
11575
|
+
// gsd-config-reload.js to reload the gsd config context whenever the user
|
|
11576
|
+
// edits .planning/config.json mid-session, eliminating the need to restart.
|
|
11577
|
+
//
|
|
11578
|
+
// The matcher "config.json" watches for changes to any file named config.json
|
|
11579
|
+
// (Claude Code matches by filename, not full path). The hook exits silently
|
|
11580
|
+
// when the changed file is not the gsd config.
|
|
11581
|
+
//
|
|
11582
|
+
// Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
|
|
11583
|
+
// verified; extend in a follow-on if empirically confirmed.
|
|
11584
|
+
if (runtime === 'claude') {
|
|
11585
|
+
if (!settings.hooks.FileChanged) {
|
|
11586
|
+
settings.hooks.FileChanged = [];
|
|
11587
|
+
}
|
|
11588
|
+
const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js');
|
|
11589
|
+
const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry =>
|
|
11590
|
+
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-config-reload'))
|
|
11591
|
+
);
|
|
11592
|
+
if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) {
|
|
11593
|
+
settings.hooks.FileChanged.push({
|
|
11594
|
+
matcher: 'config.json',
|
|
11595
|
+
hooks: [
|
|
11596
|
+
{
|
|
11597
|
+
type: 'command',
|
|
11598
|
+
command: configReloadCommand,
|
|
11599
|
+
timeout: 8
|
|
11600
|
+
}
|
|
11601
|
+
]
|
|
11602
|
+
});
|
|
11603
|
+
console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`);
|
|
11604
|
+
} else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) {
|
|
11605
|
+
console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`);
|
|
11606
|
+
} else if (!alreadyHasConfigReload && !configReloadCommand) {
|
|
11607
|
+
console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`);
|
|
11608
|
+
}
|
|
11609
|
+
}
|
|
11610
|
+
// ── end FileChanged hook ────────────────────────────────────────────────────
|
|
11611
|
+
}
|
|
11612
|
+
|
|
11613
|
+
// ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
|
|
11614
|
+
// Detect `hooksConfig.enabled: false` in the already-loaded settings object
|
|
11615
|
+
// and emit a clear warning. When this field is false the Gemini CLI silently
|
|
11616
|
+
// disables ALL hook execution — gsd hooks are registered but will never run.
|
|
11617
|
+
// The check is read-only (warning only; we do not mutate hooksConfig).
|
|
11618
|
+
// Note: we use the in-memory `settings` object (already read from disk and
|
|
11619
|
+
// cleaned up by validateHookFields/cleanupOrphanedHooks above) rather than
|
|
11620
|
+
// re-reading settings.json, avoiding a TOCTOU window between the two reads.
|
|
11621
|
+
if (isGemini && settings && settings.hooksConfig && settings.hooksConfig.enabled === false) {
|
|
11622
|
+
console.warn(
|
|
11623
|
+
` ${yellow}⚠${reset} Warning: hooksConfig.enabled is false in your Gemini settings.json.\n` +
|
|
11624
|
+
` gsd-core hooks are registered but will NOT run until you set\n` +
|
|
11625
|
+
` hooksConfig.enabled: true in ${path.join(targetDir, 'settings.json')}.`
|
|
11626
|
+
);
|
|
10097
11627
|
}
|
|
11628
|
+
// ── end hooksConfig.enabled check ────────────────────────────────────────
|
|
10098
11629
|
|
|
10099
11630
|
// Compute the update-banner hook command alongside the others so
|
|
10100
11631
|
// installAllRuntimes can register it at finalize time when the user opts
|
|
@@ -10106,6 +11637,68 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10106
11637
|
? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
|
|
10107
11638
|
: localCmd('gsd-update-banner.js'));
|
|
10108
11639
|
|
|
11640
|
+
// #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs.
|
|
11641
|
+
// Both fresh and upgrade paths apply only when worktrees are enabled for the project.
|
|
11642
|
+
// Never applies to global installs, non-Claude runtimes, or when the user already
|
|
11643
|
+
// has an explicit baseRef in EITHER settings.local.json OR settings.json (no-clobber).
|
|
11644
|
+
// Guard: skip entirely when settings is not a plain object (e.g. parsed to [] or primitive)
|
|
11645
|
+
// to avoid crashing applyWorktreeBaseRef on unexpected top-level shapes.
|
|
11646
|
+
if (isLocalClaude && settings !== null && typeof settings === 'object' && !Array.isArray(settings)) {
|
|
11647
|
+
// Read shared settings.json baseRef so no-clobber spans both files (#683 FIX 1).
|
|
11648
|
+
// shared settings.json no-clobber is checked here; settings.local.json no-clobber
|
|
11649
|
+
// is enforced inside applyWorktreeBaseRef itself.
|
|
11650
|
+
const sharedSettingsForBaseRef = readSettings(path.join(targetDir, 'settings.json')) || {};
|
|
11651
|
+
const sharedBaseRef = readBaseRefFromSettings(sharedSettingsForBaseRef);
|
|
11652
|
+
|
|
11653
|
+
// Compute worktrees-enabled ONCE for both fresh and upgrade paths (FIX A: DRY + consistency).
|
|
11654
|
+
// Read workflow.use_worktrees from .planning/config.json by walking up from
|
|
11655
|
+
// targetDir (same walk-up pattern as readGsdRuntimeProfileResolver). Defaults
|
|
11656
|
+
// to enabled (true) when the file is missing, unreadable, or the key is absent;
|
|
11657
|
+
// only boolean false disables (string "false" stays enabled).
|
|
11658
|
+
let worktreesEnabled = true; // default: enabled
|
|
11659
|
+
try {
|
|
11660
|
+
let probeDir = path.resolve(targetDir);
|
|
11661
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
11662
|
+
const candidate = path.join(probeDir, '.planning', 'config.json');
|
|
11663
|
+
if (fs.existsSync(candidate)) {
|
|
11664
|
+
try {
|
|
11665
|
+
const parsed = JSON.parse(stripJsonComments(fs.readFileSync(candidate, 'utf-8')));
|
|
11666
|
+
if (parsed && typeof parsed === 'object' &&
|
|
11667
|
+
parsed.workflow && parsed.workflow.use_worktrees === false) {
|
|
11668
|
+
worktreesEnabled = false;
|
|
11669
|
+
}
|
|
11670
|
+
} catch {
|
|
11671
|
+
// Malformed config.json — treat as enabled (safe fallback).
|
|
11672
|
+
}
|
|
11673
|
+
break;
|
|
11674
|
+
}
|
|
11675
|
+
const parent = path.dirname(probeDir);
|
|
11676
|
+
if (parent === probeDir) break;
|
|
11677
|
+
probeDir = parent;
|
|
11678
|
+
}
|
|
11679
|
+
} catch {
|
|
11680
|
+
// Any unexpected error reading .planning — default to enabled.
|
|
11681
|
+
}
|
|
11682
|
+
|
|
11683
|
+
if (worktreesEnabled && sharedBaseRef === null) {
|
|
11684
|
+
if (!priorInstallExisted) {
|
|
11685
|
+
// Fresh install — apply no-clobber baseRef set.
|
|
11686
|
+
// canonical no-clobber logic: src/worktree-base-ref.cts applyWorktreeBaseRef (#683)
|
|
11687
|
+
const { changed } = applyWorktreeBaseRef(settings);
|
|
11688
|
+
if (changed) {
|
|
11689
|
+
console.log(` ${green}✓${reset} Set worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
|
|
11690
|
+
}
|
|
11691
|
+
} else {
|
|
11692
|
+
// Upgrade — auto-apply no-clobber baseRef set when worktrees are enabled.
|
|
11693
|
+
const { changed } = applyWorktreeBaseRef(settings);
|
|
11694
|
+
if (changed) {
|
|
11695
|
+
console.log(` ${green}✓${reset} Enabled worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
|
|
11696
|
+
}
|
|
11697
|
+
}
|
|
11698
|
+
}
|
|
11699
|
+
// When worktreesEnabled is false: do nothing, print nothing (both fresh and upgrade).
|
|
11700
|
+
}
|
|
11701
|
+
|
|
10109
11702
|
persistActiveProfileMarker();
|
|
10110
11703
|
return {
|
|
10111
11704
|
settingsPath,
|
|
@@ -10130,6 +11723,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
10130
11723
|
const isWindsurf = runtime === 'windsurf';
|
|
10131
11724
|
const isTrae = runtime === 'trae';
|
|
10132
11725
|
const isCline = runtime === 'cline';
|
|
11726
|
+
const configIntent = resolveRuntimeConfigIntent(runtime);
|
|
10133
11727
|
|
|
10134
11728
|
if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) {
|
|
10135
11729
|
if (!isGlobal && !forceStatusline) {
|
|
@@ -10182,6 +11776,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
10182
11776
|
}
|
|
10183
11777
|
}
|
|
10184
11778
|
|
|
11779
|
+
// #768 — Pre-populate permissions.allow/deny for Claude Code installs.
|
|
11780
|
+
// Merges GSD-owned entries non-destructively (preserves existing user permissions).
|
|
11781
|
+
// Scoped to Claude only: gemini/antigravity/qwen/hermes/codebuddy also write
|
|
11782
|
+
// settings.json but use different runtimes and do not use these permission strings.
|
|
11783
|
+
if (runtime === 'claude') {
|
|
11784
|
+
mergeClaudePermissions(settings);
|
|
11785
|
+
}
|
|
11786
|
+
|
|
10185
11787
|
// Write settings when runtime supports settings.json.
|
|
10186
11788
|
// #3002 CR: defense-in-depth — re-run validateHookFields right before
|
|
10187
11789
|
// serialization. The push-site guards above already skip null-command
|
|
@@ -10189,17 +11791,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
10189
11791
|
// {type: 'command', command: null} items that the runtime hook schema
|
|
10190
11792
|
// rejects at parse time. validateHookFields filters those out so the file
|
|
10191
11793
|
// we write is always schema-valid.
|
|
10192
|
-
if (
|
|
11794
|
+
if (configIntent.writesSharedSettings) {
|
|
10193
11795
|
writeSettings(settingsPath, validateHookFields(settings));
|
|
10194
11796
|
}
|
|
10195
11797
|
|
|
10196
11798
|
// Configure OpenCode permissions
|
|
10197
|
-
if (
|
|
11799
|
+
if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
|
|
10198
11800
|
configureOpencodePermissions(isGlobal, configDir);
|
|
10199
11801
|
}
|
|
10200
11802
|
|
|
10201
11803
|
// Configure Kilo permissions
|
|
10202
|
-
if (
|
|
11804
|
+
if (configIntent.finishPermissionWriter === 'kilo') {
|
|
10203
11805
|
configureKiloPermissions(isGlobal, configDir);
|
|
10204
11806
|
}
|
|
10205
11807
|
|
|
@@ -10543,7 +12145,7 @@ function promptLocation(runtimes) {
|
|
|
10543
12145
|
});
|
|
10544
12146
|
|
|
10545
12147
|
const pathExamples = runtimes.map(r => {
|
|
10546
|
-
const globalPath =
|
|
12148
|
+
const globalPath = getGlobalConfigDir(r, explicitConfigDir);
|
|
10547
12149
|
return globalPath.replace(os.homedir(), '~');
|
|
10548
12150
|
}).join(', ');
|
|
10549
12151
|
|
|
@@ -10915,8 +12517,10 @@ module.exports = {
|
|
|
10915
12517
|
normalizeAgentBodyForRuntime,
|
|
10916
12518
|
yamlIdentifier,
|
|
10917
12519
|
computePathPrefix,
|
|
12520
|
+
applyRuntimeContentRewritesInPlace,
|
|
10918
12521
|
getCodexSkillAdapterHeader,
|
|
10919
12522
|
convertClaudeCommandToCursorSkill,
|
|
12523
|
+
convertClaudeCommandToCursorCommand,
|
|
10920
12524
|
convertClaudeAgentToCursorAgent,
|
|
10921
12525
|
convertClaudeToGeminiMarkdown,
|
|
10922
12526
|
convertSlashCommandsToGeminiMentions,
|
|
@@ -10924,6 +12528,8 @@ module.exports = {
|
|
|
10924
12528
|
convertClaudeToGeminiAgent,
|
|
10925
12529
|
convertClaudeAgentToCodexAgent,
|
|
10926
12530
|
generateCodexAgentToml,
|
|
12531
|
+
generateCodexSkillMetadataYaml,
|
|
12532
|
+
writeCodexSkillMetadataFiles,
|
|
10927
12533
|
generateCodexConfigBlock,
|
|
10928
12534
|
stripGsdFromCodexConfig,
|
|
10929
12535
|
migrateCodexHooksMapFormat,
|
|
@@ -10943,15 +12549,21 @@ module.exports = {
|
|
|
10943
12549
|
install,
|
|
10944
12550
|
installAllRuntimes,
|
|
10945
12551
|
uninstall,
|
|
12552
|
+
convertSlashCommandsToCodexSkillMentions,
|
|
10946
12553
|
convertClaudeCommandToCodexSkill,
|
|
10947
12554
|
convertClaudeToOpencodeFrontmatter,
|
|
10948
12555
|
convertClaudeToKiloFrontmatter,
|
|
12556
|
+
convertClaudeCommandToOpencodeSkill,
|
|
12557
|
+
convertClaudeCommandToKiloSkill,
|
|
10949
12558
|
configureOpencodePermissions,
|
|
10950
12559
|
neutralizeAgentReferences,
|
|
12560
|
+
// #768 — Claude Code permissions pre-population
|
|
12561
|
+
mergeClaudePermissions,
|
|
12562
|
+
GSD_CLAUDE_ALLOW_PERMISSIONS,
|
|
12563
|
+
GSD_CLAUDE_DENY_PERMISSIONS,
|
|
10951
12564
|
GSD_CODEX_MARKER,
|
|
10952
12565
|
CODEX_AGENT_SANDBOX,
|
|
10953
12566
|
getDirName,
|
|
10954
|
-
getGlobalDir,
|
|
10955
12567
|
getConfigDirFromHome,
|
|
10956
12568
|
resolveKiloConfigPath,
|
|
10957
12569
|
configureKiloPermissions,
|
|
@@ -10964,6 +12576,9 @@ module.exports = {
|
|
|
10964
12576
|
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
|
|
10965
12577
|
mergeCopilotInstructions,
|
|
10966
12578
|
stripGsdFromCopilotInstructions,
|
|
12579
|
+
GSD_COPILOT_HOOK_FILE,
|
|
12580
|
+
buildCopilotHookConfig,
|
|
12581
|
+
writeCopilotHookConfig,
|
|
10967
12582
|
convertClaudeToAntigravityContent,
|
|
10968
12583
|
convertClaudeCommandToAntigravitySkill,
|
|
10969
12584
|
convertClaudeAgentToAntigravityAgent,
|
|
@@ -10980,9 +12595,27 @@ module.exports = {
|
|
|
10980
12595
|
convertClaudeAgentToTraeAgent,
|
|
10981
12596
|
convertClaudeToCodebuddyMarkdown,
|
|
10982
12597
|
convertClaudeCommandToCodebuddySkill,
|
|
12598
|
+
convertClaudeCommandToCodebuddyCommand,
|
|
10983
12599
|
convertClaudeAgentToCodebuddyAgent,
|
|
10984
12600
|
convertClaudeToCliineMarkdown,
|
|
12601
|
+
convertClaudeCommandToClineSkill,
|
|
10985
12602
|
convertClaudeAgentToClineAgent,
|
|
12603
|
+
buildClineRulesBody,
|
|
12604
|
+
buildClineAgentsMdBody,
|
|
12605
|
+
buildClinePreToolUseHook,
|
|
12606
|
+
writeClineArtifacts,
|
|
12607
|
+
mergeGsdAgentsMd,
|
|
12608
|
+
GSD_CURSOR_SESSION_HOOK_SCRIPT,
|
|
12609
|
+
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
|
12610
|
+
GSD_CURSOR_HOOK_MARKER,
|
|
12611
|
+
buildCursorHookEntry,
|
|
12612
|
+
isManagedCursorHookEntry,
|
|
12613
|
+
reconcileCursorHooksJson,
|
|
12614
|
+
writeCursorHooksJson,
|
|
12615
|
+
removeCursorHooksJson,
|
|
12616
|
+
stripGsdFromAgentsMd,
|
|
12617
|
+
GSD_AGENTS_MD_MARKER,
|
|
12618
|
+
GSD_AGENTS_MD_CLOSE_MARKER,
|
|
10986
12619
|
writeManifest,
|
|
10987
12620
|
saveLocalPatches,
|
|
10988
12621
|
reportLocalPatches,
|
|
@@ -11011,11 +12644,16 @@ module.exports = {
|
|
|
11011
12644
|
rewriteLegacyCodexHookBlock,
|
|
11012
12645
|
buildCodexHookWindowsShimIR,
|
|
11013
12646
|
ensureCodexHooksJsonSessionStart,
|
|
12647
|
+
ensureCodexHooksJsonEvent,
|
|
12648
|
+
removeCodexHooksJsonEvent,
|
|
12649
|
+
reconcileCodexHooksJsonEvent,
|
|
11014
12650
|
readGsdCommandNames,
|
|
11015
12651
|
installRuntimeArtifacts,
|
|
12652
|
+
installOpencodeFamilySkills,
|
|
11016
12653
|
uninstallRuntimeArtifacts,
|
|
11017
12654
|
parseConfigDirFromArgs,
|
|
11018
12655
|
cleanupLegacyGsdCc,
|
|
12656
|
+
_applyRuntimeRewrites,
|
|
11019
12657
|
};
|
|
11020
12658
|
|
|
11021
12659
|
// Main logic — only run when not loaded as a module for testing
|
|
@@ -11042,7 +12680,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
|
|
|
11042
12680
|
console.error('Usage: node install.js --skills-root <runtime>');
|
|
11043
12681
|
process.exit(1);
|
|
11044
12682
|
}
|
|
11045
|
-
const globalDir =
|
|
12683
|
+
const globalDir = getGlobalConfigDir(runtimeArg, null);
|
|
11046
12684
|
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
|
|
11047
12685
|
// Other runtimes use a flat skills/ root.
|
|
11048
12686
|
const skillsRoot = runtimeArg === 'hermes'
|