@opengsd/gsd-core 1.4.0-rc.1 → 1.4.0
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-ai-researcher.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-domain-researcher.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-eval-planner.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-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 +1 -1
- package/agents/gsd-user-profiler.md +1 -1
- package/bin/install.js +1911 -354
- package/commands/gsd/autonomous.md +2 -0
- package/commands/gsd/execute-phase.md +2 -0
- package/commands/gsd/plan-phase.md +2 -0
- 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 +58 -4
- 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/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/shell-command-projection.cjs +6 -0
- package/gsd-core/bin/lib/surface.cjs +54 -11
- package/gsd-core/workflows/help/modes/full.md +2 -1
- package/gsd-core/workflows/review.md +2 -2
- package/gsd-core/workflows/update.md +32 -5
- package/hooks/dist/gsd-config-reload.js +133 -0
- package/hooks/dist/gsd-cursor-post-tool.js +75 -0
- package/hooks/dist/gsd-cursor-session-start.js +52 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-config-reload.js +133 -0
- package/hooks/gsd-cursor-post-tool.js +75 -0
- package/hooks/gsd-cursor-session-start.js +52 -0
- package/hooks/hooks.json +69 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +5 -1
- package/scripts/build-hooks.js +7 -0
- package/scripts/changeset/cli.cjs +53 -10
- package/scripts/ci-test-scope.cjs +120 -18
- package/scripts/issue-dedupe.cjs +278 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-notes/discord-release-summary.cjs +373 -0
- package/scripts/research-profiles.cjs +3 -3
- package/scripts/sync-manifest-versions.cjs +119 -0
package/bin/install.js
CHANGED
|
@@ -30,11 +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');
|
|
34
35
|
const {
|
|
35
36
|
applyWorktreeBaseRef,
|
|
36
37
|
readBaseRefFromSettings,
|
|
37
38
|
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
|
|
39
|
+
const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
|
|
@@ -95,10 +97,112 @@ function isCodexHooksFeatureKey(key) {
|
|
|
95
97
|
return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
|
|
96
98
|
}
|
|
97
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
|
+
|
|
98
163
|
// Copilot instructions marker constants
|
|
99
164
|
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by gsd-core installer -->';
|
|
100
165
|
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
|
|
101
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
|
+
|
|
102
206
|
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
|
|
103
207
|
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
|
|
104
208
|
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
|
|
@@ -407,218 +511,6 @@ function getConfigDirFromHome(runtime, isGlobal) {
|
|
|
407
511
|
return "'.claude'";
|
|
408
512
|
}
|
|
409
513
|
|
|
410
|
-
/**
|
|
411
|
-
* Get the global config directory for OpenCode
|
|
412
|
-
* OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
|
|
413
|
-
* Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
|
|
414
|
-
*/
|
|
415
|
-
function getOpencodeGlobalDir() {
|
|
416
|
-
// 1. Explicit OPENCODE_CONFIG_DIR env var
|
|
417
|
-
if (process.env.OPENCODE_CONFIG_DIR) {
|
|
418
|
-
return expandTilde(process.env.OPENCODE_CONFIG_DIR);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// 2. OPENCODE_CONFIG env var (use its directory)
|
|
422
|
-
if (process.env.OPENCODE_CONFIG) {
|
|
423
|
-
return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// 3. XDG_CONFIG_HOME/opencode
|
|
427
|
-
if (process.env.XDG_CONFIG_HOME) {
|
|
428
|
-
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// 4. Default: ~/.config/opencode (XDG default)
|
|
432
|
-
return path.join(os.homedir(), '.config', 'opencode');
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Get the global config directory for Kilo
|
|
437
|
-
* Kilo follows XDG Base Directory spec and uses ~/.config/kilo/
|
|
438
|
-
* Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo
|
|
439
|
-
*/
|
|
440
|
-
function getKiloGlobalDir() {
|
|
441
|
-
// 1. Explicit KILO_CONFIG_DIR env var
|
|
442
|
-
if (process.env.KILO_CONFIG_DIR) {
|
|
443
|
-
return expandTilde(process.env.KILO_CONFIG_DIR);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// 2. KILO_CONFIG env var (use its directory)
|
|
447
|
-
if (process.env.KILO_CONFIG) {
|
|
448
|
-
return path.dirname(expandTilde(process.env.KILO_CONFIG));
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// 3. XDG_CONFIG_HOME/kilo
|
|
452
|
-
if (process.env.XDG_CONFIG_HOME) {
|
|
453
|
-
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// 4. Default: ~/.config/kilo (XDG default)
|
|
457
|
-
return path.join(os.homedir(), '.config', 'kilo');
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Get the global config directory for a runtime
|
|
462
|
-
* @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
|
|
463
|
-
* @param {string|null} explicitDir - Explicit directory from --config-dir flag
|
|
464
|
-
*/
|
|
465
|
-
function getGlobalDir(runtime, explicitDir = null) {
|
|
466
|
-
if (runtime === 'opencode') {
|
|
467
|
-
// For OpenCode, --config-dir overrides env vars
|
|
468
|
-
if (explicitDir) {
|
|
469
|
-
return expandTilde(explicitDir);
|
|
470
|
-
}
|
|
471
|
-
return getOpencodeGlobalDir();
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
if (runtime === 'kilo') {
|
|
475
|
-
// For Kilo, --config-dir overrides env vars
|
|
476
|
-
if (explicitDir) {
|
|
477
|
-
return expandTilde(explicitDir);
|
|
478
|
-
}
|
|
479
|
-
return getKiloGlobalDir();
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (runtime === 'gemini') {
|
|
483
|
-
// Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
|
|
484
|
-
if (explicitDir) {
|
|
485
|
-
return expandTilde(explicitDir);
|
|
486
|
-
}
|
|
487
|
-
if (process.env.GEMINI_CONFIG_DIR) {
|
|
488
|
-
return expandTilde(process.env.GEMINI_CONFIG_DIR);
|
|
489
|
-
}
|
|
490
|
-
return path.join(os.homedir(), '.gemini');
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (runtime === 'codex') {
|
|
494
|
-
// Codex: --config-dir > CODEX_HOME > ~/.codex
|
|
495
|
-
if (explicitDir) {
|
|
496
|
-
return expandTilde(explicitDir);
|
|
497
|
-
}
|
|
498
|
-
if (process.env.CODEX_HOME) {
|
|
499
|
-
return expandTilde(process.env.CODEX_HOME);
|
|
500
|
-
}
|
|
501
|
-
return path.join(os.homedir(), '.codex');
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
if (runtime === 'copilot') {
|
|
505
|
-
// Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
|
|
506
|
-
if (explicitDir) {
|
|
507
|
-
return expandTilde(explicitDir);
|
|
508
|
-
}
|
|
509
|
-
if (process.env.COPILOT_CONFIG_DIR) {
|
|
510
|
-
return expandTilde(process.env.COPILOT_CONFIG_DIR);
|
|
511
|
-
}
|
|
512
|
-
return path.join(os.homedir(), '.copilot');
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (runtime === 'antigravity') {
|
|
516
|
-
// Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > auto-detected
|
|
517
|
-
// ~/.gemini/{antigravity,antigravity-ide,antigravity-cli}
|
|
518
|
-
if (explicitDir) {
|
|
519
|
-
return expandTilde(explicitDir);
|
|
520
|
-
}
|
|
521
|
-
return resolveAntigravityGlobalDir();
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (runtime === 'cursor') {
|
|
525
|
-
// Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor
|
|
526
|
-
if (explicitDir) {
|
|
527
|
-
return expandTilde(explicitDir);
|
|
528
|
-
}
|
|
529
|
-
if (process.env.CURSOR_CONFIG_DIR) {
|
|
530
|
-
return expandTilde(process.env.CURSOR_CONFIG_DIR);
|
|
531
|
-
}
|
|
532
|
-
return path.join(os.homedir(), '.cursor');
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if (runtime === 'windsurf') {
|
|
536
|
-
// Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf
|
|
537
|
-
if (explicitDir) {
|
|
538
|
-
return expandTilde(explicitDir);
|
|
539
|
-
}
|
|
540
|
-
if (process.env.WINDSURF_CONFIG_DIR) {
|
|
541
|
-
return expandTilde(process.env.WINDSURF_CONFIG_DIR);
|
|
542
|
-
}
|
|
543
|
-
return path.join(os.homedir(), '.codeium', 'windsurf');
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
if (runtime === 'augment') {
|
|
547
|
-
// Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
|
|
548
|
-
if (explicitDir) {
|
|
549
|
-
return expandTilde(explicitDir);
|
|
550
|
-
}
|
|
551
|
-
if (process.env.AUGMENT_CONFIG_DIR) {
|
|
552
|
-
return expandTilde(process.env.AUGMENT_CONFIG_DIR);
|
|
553
|
-
}
|
|
554
|
-
return path.join(os.homedir(), '.augment');
|
|
555
|
-
}
|
|
556
|
-
if (runtime === 'trae') {
|
|
557
|
-
// Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae
|
|
558
|
-
if (explicitDir) {
|
|
559
|
-
return expandTilde(explicitDir);
|
|
560
|
-
}
|
|
561
|
-
if (process.env.TRAE_CONFIG_DIR) {
|
|
562
|
-
return expandTilde(process.env.TRAE_CONFIG_DIR);
|
|
563
|
-
}
|
|
564
|
-
return path.join(os.homedir(), '.trae');
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
if (runtime === 'qwen') {
|
|
568
|
-
if (explicitDir) {
|
|
569
|
-
return expandTilde(explicitDir);
|
|
570
|
-
}
|
|
571
|
-
if (process.env.QWEN_CONFIG_DIR) {
|
|
572
|
-
return expandTilde(process.env.QWEN_CONFIG_DIR);
|
|
573
|
-
}
|
|
574
|
-
return path.join(os.homedir(), '.qwen');
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (runtime === 'hermes') {
|
|
578
|
-
// Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes
|
|
579
|
-
// Honors HERMES_HOME which Hermes users set for profile mode / Docker
|
|
580
|
-
// deploys (docs: https://hermes-agent.nousresearch.com/docs).
|
|
581
|
-
if (explicitDir) {
|
|
582
|
-
return expandTilde(explicitDir);
|
|
583
|
-
}
|
|
584
|
-
if (process.env.HERMES_HOME) {
|
|
585
|
-
return expandTilde(process.env.HERMES_HOME);
|
|
586
|
-
}
|
|
587
|
-
return path.join(os.homedir(), '.hermes');
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if (runtime === 'codebuddy') {
|
|
591
|
-
// CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy
|
|
592
|
-
if (explicitDir) {
|
|
593
|
-
return expandTilde(explicitDir);
|
|
594
|
-
}
|
|
595
|
-
if (process.env.CODEBUDDY_CONFIG_DIR) {
|
|
596
|
-
return expandTilde(process.env.CODEBUDDY_CONFIG_DIR);
|
|
597
|
-
}
|
|
598
|
-
return path.join(os.homedir(), '.codebuddy');
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
if (runtime === 'cline') {
|
|
602
|
-
// Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline
|
|
603
|
-
if (explicitDir) {
|
|
604
|
-
return expandTilde(explicitDir);
|
|
605
|
-
}
|
|
606
|
-
if (process.env.CLINE_CONFIG_DIR) {
|
|
607
|
-
return expandTilde(process.env.CLINE_CONFIG_DIR);
|
|
608
|
-
}
|
|
609
|
-
return path.join(os.homedir(), '.cline');
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
|
|
613
|
-
if (explicitDir) {
|
|
614
|
-
return expandTilde(explicitDir);
|
|
615
|
-
}
|
|
616
|
-
if (process.env.CLAUDE_CONFIG_DIR) {
|
|
617
|
-
return expandTilde(process.env.CLAUDE_CONFIG_DIR);
|
|
618
|
-
}
|
|
619
|
-
return path.join(os.homedir(), '.claude');
|
|
620
|
-
}
|
|
621
|
-
|
|
622
514
|
const banner = '\n' +
|
|
623
515
|
cyan + ' ██████╗ ███████╗██████╗\n' +
|
|
624
516
|
' ██╔════╝ ██╔════╝██╔══██╗\n' +
|
|
@@ -687,20 +579,10 @@ if (hasUninstall) {
|
|
|
687
579
|
|
|
688
580
|
// Show help if requested
|
|
689
581
|
if (hasHelp) {
|
|
690
|
-
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`);
|
|
691
583
|
process.exit(0);
|
|
692
584
|
}
|
|
693
585
|
|
|
694
|
-
/**
|
|
695
|
-
* Expand ~ to home directory (shell doesn't expand in env vars passed to node)
|
|
696
|
-
*/
|
|
697
|
-
function expandTilde(filePath) {
|
|
698
|
-
if (filePath && filePath.startsWith('~/')) {
|
|
699
|
-
return path.join(os.homedir(), filePath.slice(2));
|
|
700
|
-
}
|
|
701
|
-
return filePath;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
586
|
/**
|
|
705
587
|
* Compute the path prefix used for `@file` references in installed command/skill
|
|
706
588
|
* markdown. For global installs into a runtime config dir under $HOME, we
|
|
@@ -995,9 +877,31 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
|
|
|
995
877
|
return { content: updated, changed };
|
|
996
878
|
}
|
|
997
879
|
|
|
998
|
-
|
|
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 = {}) {
|
|
999
900
|
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
1000
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;
|
|
1001
905
|
let parsed = {};
|
|
1002
906
|
let currentContent = null;
|
|
1003
907
|
if (fs.existsSync(hooksJsonPath)) {
|
|
@@ -1016,22 +920,22 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1016
920
|
const usesNestedHooksObject =
|
|
1017
921
|
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
|
|
1018
922
|
const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
|
|
1019
|
-
const
|
|
923
|
+
const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
|
|
1020
924
|
|
|
1021
925
|
let removedLegacy = false;
|
|
1022
|
-
const
|
|
1023
|
-
for (const entry of
|
|
926
|
+
const sanitizedEntries = [];
|
|
927
|
+
for (const entry of eventEntries) {
|
|
1024
928
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
1025
929
|
const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
1026
930
|
if (originalHooks.length === 0) {
|
|
1027
|
-
|
|
931
|
+
sanitizedEntries.push(entry);
|
|
1028
932
|
continue;
|
|
1029
933
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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,
|
|
1035
939
|
configDir: targetDir,
|
|
1036
940
|
});
|
|
1037
941
|
if (managed) removedLegacy = true;
|
|
@@ -1039,24 +943,26 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1039
943
|
});
|
|
1040
944
|
if (keptHooks.length === 0) continue;
|
|
1041
945
|
const nextEntry = { ...entry, hooks: keptHooks };
|
|
1042
|
-
|
|
946
|
+
sanitizedEntries.push(nextEntry);
|
|
1043
947
|
}
|
|
1044
948
|
|
|
1045
949
|
if (managedCommand) {
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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;
|
|
1058
964
|
} else {
|
|
1059
|
-
delete hookTable
|
|
965
|
+
delete hookTable[eventName];
|
|
1060
966
|
}
|
|
1061
967
|
if (usesNestedHooksObject) parsed.hooks = hookTable;
|
|
1062
968
|
|
|
@@ -1070,6 +976,18 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1070
976
|
return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
|
|
1071
977
|
}
|
|
1072
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
|
+
|
|
1073
991
|
/**
|
|
1074
992
|
* Build a typed IR for the Codex hook .cmd shim used on Windows (#3426).
|
|
1075
993
|
*
|
|
@@ -1151,8 +1069,14 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
|
|
|
1151
1069
|
* 2) { "hooks": { "SessionStart": [...] } }
|
|
1152
1070
|
*
|
|
1153
1071
|
* On Windows, writes a .cmd shim alongside the .js hook file and uses the
|
|
1154
|
-
* .cmd path as the hook command to avoid the `bash.exe: cannot execute
|
|
1155
|
-
* 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).
|
|
1156
1080
|
*
|
|
1157
1081
|
* @param {string} targetDir
|
|
1158
1082
|
* @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
|
|
@@ -1164,7 +1088,18 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1164
1088
|
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
1165
1089
|
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1166
1090
|
|
|
1167
|
-
|
|
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');
|
|
1168
1103
|
|
|
1169
1104
|
let managedCommand;
|
|
1170
1105
|
if (platform === 'win32') {
|
|
@@ -1201,7 +1136,96 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1201
1136
|
}
|
|
1202
1137
|
|
|
1203
1138
|
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1204
|
-
|
|
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 });
|
|
1205
1229
|
}
|
|
1206
1230
|
|
|
1207
1231
|
function removeCodexHooksJsonSessionStart(targetDir) {
|
|
@@ -1773,11 +1797,11 @@ function getCommitAttribution(runtime) {
|
|
|
1773
1797
|
const resolveConfigPath = runtime === 'opencode'
|
|
1774
1798
|
? resolveOpencodeConfigPath
|
|
1775
1799
|
: resolveKiloConfigPath;
|
|
1776
|
-
const config = readSettings(resolveConfigPath(
|
|
1800
|
+
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
|
|
1777
1801
|
result = (config && config.disable_ai_attribution === true) ? null : undefined;
|
|
1778
1802
|
} else if (runtime === 'gemini') {
|
|
1779
1803
|
// Gemini: check gemini settings.json for attribution config
|
|
1780
|
-
const settings = readSettings(path.join(
|
|
1804
|
+
const settings = readSettings(path.join(getGlobalConfigDir('gemini', explicitConfigDir), 'settings.json'));
|
|
1781
1805
|
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
|
|
1782
1806
|
result = undefined;
|
|
1783
1807
|
} else if (settings.attribution.commit === '') {
|
|
@@ -1787,7 +1811,7 @@ function getCommitAttribution(runtime) {
|
|
|
1787
1811
|
}
|
|
1788
1812
|
} else if (runtime === 'claude') {
|
|
1789
1813
|
// Claude Code
|
|
1790
|
-
const settings = readSettings(path.join(
|
|
1814
|
+
const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
|
|
1791
1815
|
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
|
|
1792
1816
|
result = undefined;
|
|
1793
1817
|
} else if (settings.attribution.commit === '') {
|
|
@@ -2097,6 +2121,40 @@ function skillFrontmatterName(skillDirName) {
|
|
|
2097
2121
|
return skillDirName;
|
|
2098
2122
|
}
|
|
2099
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
|
+
|
|
2100
2158
|
/**
|
|
2101
2159
|
* Convert a Claude command (.md) to a Claude skill (SKILL.md).
|
|
2102
2160
|
* Claude Code is the native format, so minimal conversion needed —
|
|
@@ -2119,6 +2177,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
|
|
2119
2177
|
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
|
2120
2178
|
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
|
|
2121
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');
|
|
2122
2184
|
|
|
2123
2185
|
// Preserve allowed-tools as YAML multiline list (Claude native format)
|
|
2124
2186
|
const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
|
|
@@ -2136,8 +2198,26 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
|
|
2136
2198
|
// Track GSD's package version so Hermes' skill_view() reports a stable
|
|
2137
2199
|
// identifier per install.
|
|
2138
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
|
+
}
|
|
2139
2213
|
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
|
|
2140
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`;
|
|
2141
2221
|
if (toolsBlock) fm += toolsBlock;
|
|
2142
2222
|
fm += '---';
|
|
2143
2223
|
|
|
@@ -2390,6 +2470,29 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
|
|
|
2390
2470
|
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
|
|
2391
2471
|
}
|
|
2392
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
|
+
|
|
2393
2496
|
/**
|
|
2394
2497
|
* Convert Claude Code agent markdown to Cursor agent format.
|
|
2395
2498
|
* Strips frontmatter fields Cursor doesn't support (color, skills),
|
|
@@ -2753,7 +2856,47 @@ function convertClaudeCommandToCodebuddySkill(content, skillName) {
|
|
|
2753
2856
|
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
|
|
2754
2857
|
// #2876: quote so YAML flow indicators (`[BETA] …`) don't break
|
|
2755
2858
|
// CodeBuddy's frontmatter parser.
|
|
2756
|
-
|
|
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');
|
|
2757
2900
|
}
|
|
2758
2901
|
|
|
2759
2902
|
function convertClaudeAgentToCodebuddyAgent(content) {
|
|
@@ -2779,9 +2922,15 @@ function convertClaudeToCliineMarkdown(content) {
|
|
|
2779
2922
|
converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules');
|
|
2780
2923
|
converted = converted.replace(/`CLAUDE\.md`/g, '`.clinerules`');
|
|
2781
2924
|
converted = converted.replace(/\bCLAUDE\.md\b/g, '.clinerules');
|
|
2925
|
+
// Slash forms first (most specific — superset of bare forms)
|
|
2782
2926
|
converted = converted.replace(/\.claude\/skills\//g, '.cline/skills/');
|
|
2783
2927
|
converted = converted.replace(/\.\/\.claude\//g, './.cline/');
|
|
2784
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');
|
|
2785
2934
|
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
|
|
2786
2935
|
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
|
|
2787
2936
|
converted = converted.replace(/\bClaude Code\b/g, 'Cline');
|
|
@@ -2798,6 +2947,43 @@ function convertClaudeAgentToClineAgent(content) {
|
|
|
2798
2947
|
return `${cleanFrontmatter}\n${body}`;
|
|
2799
2948
|
}
|
|
2800
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
|
+
|
|
2801
2987
|
// ── End Cline converters ─────────────────────────────────────────────────────
|
|
2802
2988
|
|
|
2803
2989
|
function convertSlashCommandsToCodexSkillMentions(content) {
|
|
@@ -3017,6 +3203,20 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
|
|
|
3017
3203
|
const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
|
|
3018
3204
|
lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
|
|
3019
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
|
+
|
|
3020
3220
|
// Agent prompts contain raw backslashes in regexes and shell snippets.
|
|
3021
3221
|
// TOML literal multiline strings preserve them without escape parsing.
|
|
3022
3222
|
lines.push(`developer_instructions = '''`);
|
|
@@ -3026,6 +3226,115 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
|
|
|
3026
3226
|
return lines.join('\n') + '\n';
|
|
3027
3227
|
}
|
|
3028
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
|
+
|
|
3029
3338
|
/**
|
|
3030
3339
|
* Generate the GSD config block for Codex config.toml.
|
|
3031
3340
|
* @param {Array<{name: string, description: string}>} agents
|
|
@@ -5208,31 +5517,531 @@ function mergeCopilotInstructions(filePath, gsdContent) {
|
|
|
5208
5517
|
return;
|
|
5209
5518
|
}
|
|
5210
5519
|
|
|
5211
|
-
// Case 3: No markers — append at end
|
|
5212
|
-
const content = existing.trimEnd() + '\n\n' + gsdBlock + '\n';
|
|
5213
|
-
fs.writeFileSync(filePath, content);
|
|
5520
|
+
// Case 3: No markers — append at end
|
|
5521
|
+
const content = existing.trimEnd() + '\n\n' + gsdBlock + '\n';
|
|
5522
|
+
fs.writeFileSync(filePath, content);
|
|
5523
|
+
}
|
|
5524
|
+
|
|
5525
|
+
/**
|
|
5526
|
+
* Strip GSD section from copilot-instructions.md content.
|
|
5527
|
+
* Returns cleaned content, or null if file should be deleted (was GSD-only).
|
|
5528
|
+
* @param {string} content - File content
|
|
5529
|
+
* @returns {string|null} - Cleaned content or null if empty
|
|
5530
|
+
*/
|
|
5531
|
+
function stripGsdFromCopilotInstructions(content) {
|
|
5532
|
+
const openIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER);
|
|
5533
|
+
const closeIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER);
|
|
5534
|
+
|
|
5535
|
+
if (openIndex !== -1 && closeIndex !== -1) {
|
|
5536
|
+
const before = content.substring(0, openIndex).trimEnd();
|
|
5537
|
+
const after = content.substring(closeIndex + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart();
|
|
5538
|
+
const cleaned = (before + (before && after ? '\n\n' : '') + after).trim();
|
|
5539
|
+
if (!cleaned) return null;
|
|
5540
|
+
return cleaned + '\n';
|
|
5541
|
+
}
|
|
5542
|
+
|
|
5543
|
+
// No markers found — nothing to strip
|
|
5544
|
+
return content;
|
|
5545
|
+
}
|
|
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 };
|
|
5214
5959
|
}
|
|
5215
5960
|
|
|
5216
5961
|
/**
|
|
5217
|
-
*
|
|
5218
|
-
*
|
|
5219
|
-
*
|
|
5220
|
-
* @
|
|
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 }}
|
|
5221
5967
|
*/
|
|
5222
|
-
function
|
|
5223
|
-
const
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
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 */ }
|
|
5232
5992
|
}
|
|
5993
|
+
return { changed: result.changed };
|
|
5994
|
+
}
|
|
5233
5995
|
|
|
5234
|
-
|
|
5235
|
-
|
|
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;
|
|
5236
6045
|
}
|
|
5237
6046
|
|
|
5238
6047
|
/**
|
|
@@ -5407,7 +6216,7 @@ function convertSlashCommandsToGeminiMentions(content) {
|
|
|
5407
6216
|
});
|
|
5408
6217
|
}
|
|
5409
6218
|
|
|
5410
|
-
function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
|
|
6219
|
+
function convertClaudeToGeminiMarkdown(content, { isCommand = false, commandName = null } = {}) {
|
|
5411
6220
|
// Apply Gemini-specific slash command namespacing
|
|
5412
6221
|
let converted = convertSlashCommandsToGeminiMentions(content);
|
|
5413
6222
|
// Gemini CLI does not expose Claude's AskUserQuestion tool. Convert body
|
|
@@ -5419,8 +6228,9 @@ function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
|
|
|
5419
6228
|
converted = stripSubTags(converted);
|
|
5420
6229
|
|
|
5421
6230
|
if (isCommand) {
|
|
5422
|
-
// Convert to Gemini TOML format
|
|
5423
|
-
|
|
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 });
|
|
5424
6234
|
}
|
|
5425
6235
|
|
|
5426
6236
|
return converted;
|
|
@@ -5855,12 +6665,85 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
|
|
|
5855
6665
|
return `---\n${newFrontmatter}\n---${body}`;
|
|
5856
6666
|
}
|
|
5857
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
|
+
|
|
5858
6732
|
/**
|
|
5859
6733
|
* Convert Claude Code markdown command to Gemini TOML format
|
|
5860
6734
|
* @param {string} content - Markdown file content with YAML frontmatter
|
|
5861
6735
|
* @returns {string} - TOML content
|
|
5862
6736
|
*/
|
|
5863
|
-
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
|
+
|
|
5864
6747
|
// Check if content has frontmatter
|
|
5865
6748
|
if (!content.startsWith('---')) {
|
|
5866
6749
|
return `prompt = ${JSON.stringify(content)}\n`;
|
|
@@ -5872,7 +6755,27 @@ function convertClaudeToGeminiToml(content) {
|
|
|
5872
6755
|
}
|
|
5873
6756
|
|
|
5874
6757
|
const frontmatter = content.substring(3, endIndex).trim();
|
|
5875
|
-
|
|
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
|
+
}
|
|
5876
6779
|
|
|
5877
6780
|
// Extract description from frontmatter
|
|
5878
6781
|
let description = '';
|
|
@@ -5907,6 +6810,31 @@ function convertClaudeToGeminiToml(content) {
|
|
|
5907
6810
|
* @param {string} pathPrefix - Path prefix for file references
|
|
5908
6811
|
* @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
|
|
5909
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
|
+
|
|
5910
6838
|
function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
|
|
5911
6839
|
if (!fs.existsSync(srcDir)) {
|
|
5912
6840
|
return;
|
|
@@ -5939,16 +6867,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
|
|
|
5939
6867
|
const destPath = path.join(destDir, destName);
|
|
5940
6868
|
|
|
5941
6869
|
let content = fs.readFileSync(srcPath, 'utf8');
|
|
5942
|
-
|
|
5943
|
-
const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
|
|
5944
|
-
const localClaudeRegex = /\.\/\.claude\//g;
|
|
5945
|
-
const opencodeDirRegex = /~\/\.opencode\//g;
|
|
5946
|
-
const kiloDirRegex = /~\/\.kilo\//g;
|
|
5947
|
-
content = content.replace(globalClaudeRegex, pathPrefix);
|
|
5948
|
-
content = content.replace(globalClaudeHomeRegex, pathPrefix);
|
|
5949
|
-
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
|
|
5950
|
-
content = content.replace(opencodeDirRegex, pathPrefix);
|
|
5951
|
-
content = content.replace(kiloDirRegex, pathPrefix);
|
|
6870
|
+
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
5952
6871
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
5953
6872
|
content = runtime === 'kilo'
|
|
5954
6873
|
? convertClaudeToKiloFrontmatter(content)
|
|
@@ -6133,7 +7052,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
|
|
6133
7052
|
if (runtime) {
|
|
6134
7053
|
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
|
|
6135
7054
|
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
|
6136
|
-
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)
|
|
6137
7056
|
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
|
|
6138
7057
|
skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName);
|
|
6139
7058
|
} else {
|
|
@@ -6190,6 +7109,46 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
|
|
|
6190
7109
|
walkAndRewrite(stagedDir);
|
|
6191
7110
|
}
|
|
6192
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
|
+
|
|
6193
7152
|
/**
|
|
6194
7153
|
* Apply the per-runtime rewrite table to a single content string.
|
|
6195
7154
|
* Extracted so it can be unit-tested independently of the filesystem walk.
|
|
@@ -6212,6 +7171,22 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
6212
7171
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
6213
7172
|
break;
|
|
6214
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
|
+
|
|
6215
7190
|
case 'cursor':
|
|
6216
7191
|
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
6217
7192
|
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
|
@@ -6254,7 +7229,14 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
6254
7229
|
content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
|
|
6255
7230
|
content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
|
|
6256
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.
|
|
6257
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);
|
|
6258
7240
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
6259
7241
|
break;
|
|
6260
7242
|
|
|
@@ -6309,7 +7291,11 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
6309
7291
|
break;
|
|
6310
7292
|
|
|
6311
7293
|
default:
|
|
6312
|
-
// 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.
|
|
6313
7299
|
break;
|
|
6314
7300
|
}
|
|
6315
7301
|
|
|
@@ -6572,8 +7558,14 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
6572
7558
|
|
|
6573
7559
|
for (const kind of layout.kinds) {
|
|
6574
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;
|
|
6575
7564
|
if (kind.kind === 'skills') {
|
|
6576
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);
|
|
6577
7569
|
}
|
|
6578
7570
|
const dest = path.join(layout.configDir, kind.destSubpath);
|
|
6579
7571
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -6595,8 +7587,8 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
6595
7587
|
|
|
6596
7588
|
if (kind.prefix === '') {
|
|
6597
7589
|
// Hermes: wipes entire dest dir — preserve anything not in staged.
|
|
6598
|
-
const stagedNames = fs.existsSync(
|
|
6599
|
-
? new Set(fs.readdirSync(
|
|
7590
|
+
const stagedNames = fs.existsSync(stagedForCopy)
|
|
7591
|
+
? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true })
|
|
6600
7592
|
.filter(e => e.isDirectory()).map(e => e.name))
|
|
6601
7593
|
: new Set();
|
|
6602
7594
|
for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
|
|
@@ -6617,7 +7609,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
6617
7609
|
}
|
|
6618
7610
|
|
|
6619
7611
|
_removeGsdEntries(dest, kind);
|
|
6620
|
-
_copyStaged(
|
|
7612
|
+
_copyStaged(stagedForCopy, dest, kind);
|
|
6621
7613
|
|
|
6622
7614
|
// Restore user-owned dirs after the prune+copy
|
|
6623
7615
|
for (const [dirName, snap] of toPreserve) {
|
|
@@ -6627,11 +7619,92 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
6627
7619
|
// For non-skills kinds (commands, agents): no user content to preserve;
|
|
6628
7620
|
// just prune stale gsd-* entries and copy new ones.
|
|
6629
7621
|
_removeGsdEntries(dest, kind);
|
|
6630
|
-
_copyStaged(
|
|
7622
|
+
_copyStaged(stagedForCopy, dest, kind);
|
|
6631
7623
|
}
|
|
6632
7624
|
}
|
|
6633
7625
|
}
|
|
6634
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
|
+
|
|
6635
7708
|
/**
|
|
6636
7709
|
* Layout-driven uninstall orchestrator.
|
|
6637
7710
|
* Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
|
|
@@ -6736,8 +7809,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
|
|
6736
7809
|
: convertClaudeToOpencodeFrontmatter(content);
|
|
6737
7810
|
fs.writeFileSync(destPath, content);
|
|
6738
7811
|
} else if (isGemini) {
|
|
6739
|
-
// Apply Gemini-specific Markdown transformations (slash commands, TOML)
|
|
6740
|
-
|
|
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 });
|
|
6741
7817
|
const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath;
|
|
6742
7818
|
fs.writeFileSync(finalPath, processed);
|
|
6743
7819
|
} else if (isCodex) {
|
|
@@ -6982,7 +8058,10 @@ const GSD_UNINSTALL_HOOKS = [
|
|
|
6982
8058
|
'gsd-statusline.js',
|
|
6983
8059
|
'gsd-check-update.js',
|
|
6984
8060
|
'gsd-check-update.cmd',
|
|
8061
|
+
'gsd-config-reload.js',
|
|
6985
8062
|
'gsd-context-monitor.js',
|
|
8063
|
+
'gsd-cursor-session-start.js',
|
|
8064
|
+
'gsd-cursor-post-tool.js',
|
|
6986
8065
|
'gsd-prompt-guard.js',
|
|
6987
8066
|
'gsd-read-guard.js',
|
|
6988
8067
|
'gsd-read-injection-scanner.js',
|
|
@@ -7016,10 +8095,14 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7016
8095
|
const isCodebuddy = runtime === 'codebuddy';
|
|
7017
8096
|
const dirName = getDirName(runtime);
|
|
7018
8097
|
|
|
7019
|
-
// 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).
|
|
7020
8101
|
const targetDir = isGlobal
|
|
7021
|
-
?
|
|
7022
|
-
:
|
|
8102
|
+
? getGlobalConfigDir(runtime, explicitConfigDir)
|
|
8103
|
+
: runtime === 'cline'
|
|
8104
|
+
? process.cwd()
|
|
8105
|
+
: path.join(process.cwd(), dirName);
|
|
7023
8106
|
|
|
7024
8107
|
const locationLabel = isGlobal
|
|
7025
8108
|
? targetDir.replace(os.homedir(), '~')
|
|
@@ -7042,6 +8125,24 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7042
8125
|
|
|
7043
8126
|
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
|
|
7044
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
|
+
|
|
7045
8146
|
// Check if target directory exists
|
|
7046
8147
|
if (!fs.existsSync(targetDir)) {
|
|
7047
8148
|
console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
|
|
@@ -7101,6 +8202,15 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7101
8202
|
removedCount++;
|
|
7102
8203
|
console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
|
|
7103
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
|
+
}
|
|
7104
8214
|
}
|
|
7105
8215
|
|
|
7106
8216
|
// 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
|
|
@@ -7119,6 +8229,99 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7119
8229
|
console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`);
|
|
7120
8230
|
}
|
|
7121
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 */ }
|
|
7122
8325
|
}
|
|
7123
8326
|
|
|
7124
8327
|
// 1c. Claude local: remove commands/gsd/ (primary local install location).
|
|
@@ -7319,8 +8522,14 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7319
8522
|
}
|
|
7320
8523
|
|
|
7321
8524
|
// Remove GSD hooks from settings — per-hook granularity to preserve
|
|
7322
|
-
// user hooks that share an entry with a GSD hook (#1755 followup)
|
|
7323
|
-
|
|
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']) {
|
|
7324
8533
|
if (settings.hooks && settings.hooks[eventName]) {
|
|
7325
8534
|
const before = JSON.stringify(settings.hooks[eventName]);
|
|
7326
8535
|
settings.hooks[eventName] = settings.hooks[eventName]
|
|
@@ -7353,6 +8562,37 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
7353
8562
|
delete settings.hooks;
|
|
7354
8563
|
}
|
|
7355
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
|
+
|
|
7356
8596
|
if (settingsModified) {
|
|
7357
8597
|
writeSettings(settingsPath, settings);
|
|
7358
8598
|
removedCount++;
|
|
@@ -7531,7 +8771,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
|
|
|
7531
8771
|
// For local installs, use ./.opencode/
|
|
7532
8772
|
// For global installs, use ~/.config/opencode/
|
|
7533
8773
|
const opencodeConfigDir = configDir || (isGlobal
|
|
7534
|
-
?
|
|
8774
|
+
? getGlobalConfigDir('opencode', explicitConfigDir)
|
|
7535
8775
|
: path.join(process.cwd(), '.opencode'));
|
|
7536
8776
|
// Ensure config directory exists
|
|
7537
8777
|
fs.mkdirSync(opencodeConfigDir, { recursive: true });
|
|
@@ -7611,7 +8851,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
|
|
|
7611
8851
|
// For local installs, use ./.kilo/
|
|
7612
8852
|
// For global installs, use ~/.config/kilo/
|
|
7613
8853
|
const kiloConfigDir = configDir || (isGlobal
|
|
7614
|
-
?
|
|
8854
|
+
? getGlobalConfigDir('kilo', explicitConfigDir)
|
|
7615
8855
|
: path.join(process.cwd(), '.kilo'));
|
|
7616
8856
|
// Ensure config directory exists
|
|
7617
8857
|
fs.mkdirSync(kiloConfigDir, { recursive: true });
|
|
@@ -7885,11 +9125,15 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
|
|
7885
9125
|
}
|
|
7886
9126
|
}
|
|
7887
9127
|
}
|
|
7888
|
-
// 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.)
|
|
7889
9131
|
if (isCline) {
|
|
7890
|
-
const
|
|
7891
|
-
|
|
7892
|
-
|
|
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
|
+
}
|
|
7893
9137
|
}
|
|
7894
9138
|
}
|
|
7895
9139
|
|
|
@@ -8254,6 +9498,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8254
9498
|
const isHermes = runtime === 'hermes';
|
|
8255
9499
|
const isCodebuddy = runtime === 'codebuddy';
|
|
8256
9500
|
const isCline = runtime === 'cline';
|
|
9501
|
+
const configIntent = resolveRuntimeConfigIntent(runtime);
|
|
8257
9502
|
const dirName = getDirName(runtime);
|
|
8258
9503
|
const src = path.join(__dirname, '..');
|
|
8259
9504
|
|
|
@@ -8291,7 +9536,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8291
9536
|
// Cline local installs write to the project root (like Claude Code) — .clinerules
|
|
8292
9537
|
// lives at the root, not inside a .cline/ subdirectory.
|
|
8293
9538
|
const targetDir = isGlobal
|
|
8294
|
-
?
|
|
9539
|
+
? getGlobalConfigDir(runtime, explicitConfigDir)
|
|
8295
9540
|
: isCline
|
|
8296
9541
|
? process.cwd()
|
|
8297
9542
|
: path.join(process.cwd(), dirName);
|
|
@@ -8660,7 +9905,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8660
9905
|
//
|
|
8661
9906
|
// Non-layout side-effects preserved inline:
|
|
8662
9907
|
// Hermes: writeHermesCategoryDescription (not a layout kind)
|
|
8663
|
-
// 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
|
|
8664
9910
|
// Gemini: conflict-detection logic (not expressible in layout)
|
|
8665
9911
|
// OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
|
|
8666
9912
|
// Claude local: copyWithPathReplacement + stale-skills cleanup
|
|
@@ -8668,15 +9914,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8668
9914
|
// Layout-driven path for all skills-based runtimes (full and minimal modes).
|
|
8669
9915
|
// applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts)
|
|
8670
9916
|
// handles per-runtime path + branding rewrites, including Qwen/Hermes.
|
|
9917
|
+
// Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782).
|
|
8671
9918
|
const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
|
|
8672
9919
|
isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
|
|
8673
|
-
(runtime === 'claude' && isGlobal)
|
|
9920
|
+
(runtime === 'claude' && isGlobal) ||
|
|
9921
|
+
(isCline && isGlobal);
|
|
8674
9922
|
|
|
8675
9923
|
if (_isSkillsRuntime) {
|
|
8676
9924
|
// Layout-driven install for skills-based runtimes (full and minimal modes)
|
|
8677
9925
|
const scope = isGlobal ? 'global' : 'local';
|
|
8678
9926
|
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
|
|
8679
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
|
+
|
|
8680
9938
|
// Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
|
|
8681
9939
|
if (isHermes) {
|
|
8682
9940
|
writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
|
|
@@ -8710,6 +9968,53 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8710
9968
|
} else {
|
|
8711
9969
|
failures.push('skills/gsd-*');
|
|
8712
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
|
+
}
|
|
8713
10018
|
}
|
|
8714
10019
|
} else if (isOpencode || isKilo) {
|
|
8715
10020
|
// OpenCode/Kilo: flat structure in command/ directory
|
|
@@ -8725,9 +10030,21 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
8725
10030
|
} else {
|
|
8726
10031
|
failures.push('command/gsd-*');
|
|
8727
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
|
+
}
|
|
8728
10044
|
} else if (isCline) {
|
|
8729
|
-
// Cline
|
|
8730
|
-
// 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).
|
|
8731
10048
|
console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
|
|
8732
10049
|
} else if (isGemini) {
|
|
8733
10050
|
// #3037: when running --local --gemini and a GSD-managed user-scope
|
|
@@ -9107,8 +10424,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9107
10424
|
}
|
|
9108
10425
|
|
|
9109
10426
|
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
|
|
9110
|
-
// Codex/Copilot/Cursor/Windsurf/Trae/Cline
|
|
9111
|
-
//
|
|
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
|
|
9112
10431
|
// ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
|
|
9113
10432
|
const hooksLibSrc = path.join(src, 'hooks', 'lib');
|
|
9114
10433
|
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
|
|
@@ -9214,7 +10533,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9214
10533
|
throw _earlyInstallErr;
|
|
9215
10534
|
}
|
|
9216
10535
|
|
|
9217
|
-
if (
|
|
10536
|
+
if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
|
|
9218
10537
|
// Capture pre-install snapshots before ANY GSD mutation
|
|
9219
10538
|
// (#2760 fix 3). On post-write schema-validation failure OR any throw
|
|
9220
10539
|
// during the mutation sequence (write failure, merge throw, etc.) we
|
|
@@ -9396,10 +10715,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9396
10715
|
}
|
|
9397
10716
|
|
|
9398
10717
|
// Copy only the hook files that Codex actually registers via its hook configuration (#2153).
|
|
9399
|
-
//
|
|
10718
|
+
// #772: added gsd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events.
|
|
9400
10719
|
// We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
|
|
9401
10720
|
// in this change (graphify auto-update support for Codex is out of scope for #3579).
|
|
9402
|
-
const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js'];
|
|
10721
|
+
const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js', 'gsd-context-monitor.js'];
|
|
9403
10722
|
const codexHooksSrc = path.join(src, 'hooks', 'dist');
|
|
9404
10723
|
if (fs.existsSync(codexHooksSrc)) {
|
|
9405
10724
|
const codexHooksDest = path.join(targetDir, 'hooks');
|
|
@@ -9529,6 +10848,39 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9529
10848
|
console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`);
|
|
9530
10849
|
}
|
|
9531
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 ────────────────────────────────────
|
|
9532
10884
|
}
|
|
9533
10885
|
} catch (e) {
|
|
9534
10886
|
// #2760 — schema-validation and write failures must be loud and fatal
|
|
@@ -9560,7 +10912,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9560
10912
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9561
10913
|
}
|
|
9562
10914
|
|
|
9563
|
-
if (
|
|
10915
|
+
if (configIntent.installSurface === 'copilot-instructions') {
|
|
9564
10916
|
// Generate copilot-instructions.md
|
|
9565
10917
|
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
|
|
9566
10918
|
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
|
|
@@ -9568,47 +10920,57 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9568
10920
|
const template = fs.readFileSync(templatePath, 'utf8');
|
|
9569
10921
|
mergeCopilotInstructions(instructionsPath, template);
|
|
9570
10922
|
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
|
|
9571
|
-
|
|
9572
|
-
|
|
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)`);
|
|
9573
10941
|
persistActiveProfileMarker();
|
|
9574
10942
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9575
10943
|
}
|
|
9576
10944
|
|
|
9577
|
-
if (
|
|
9578
|
-
// Cursor
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
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 });
|
|
9585
10956
|
persistActiveProfileMarker();
|
|
9586
10957
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9587
10958
|
}
|
|
9588
10959
|
|
|
9589
|
-
if (
|
|
9590
|
-
// Trae
|
|
10960
|
+
if (configIntent.installSurface === 'profile-marker-only') {
|
|
10961
|
+
// Windsurf/Trae use skills — no config.toml, no settings.json hooks needed
|
|
9591
10962
|
persistActiveProfileMarker();
|
|
9592
10963
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9593
10964
|
}
|
|
9594
10965
|
|
|
9595
|
-
if (
|
|
9596
|
-
// Cline uses
|
|
9597
|
-
|
|
9598
|
-
|
|
9599
|
-
|
|
9600
|
-
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
'- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
|
|
9604
|
-
'- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
|
|
9605
|
-
'- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
|
|
9606
|
-
'- Do not apply GSD workflows unless the user explicitly asks for them.',
|
|
9607
|
-
'- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
|
|
9608
|
-
' step to the user using Cline\'s ask_user tool after completing it.',
|
|
9609
|
-
].join('\n') + '\n';
|
|
9610
|
-
fs.writeFileSync(clinerulesDest, clinerules);
|
|
9611
|
-
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 });
|
|
9612
10974
|
persistActiveProfileMarker();
|
|
9613
10975
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
9614
10976
|
}
|
|
@@ -9759,6 +11121,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9759
11121
|
const readInjectionScannerCommand = isGlobal
|
|
9760
11122
|
? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts)
|
|
9761
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');
|
|
9762
11127
|
|
|
9763
11128
|
// #3002 CR: when resolveNodeRunner() returns null, every dependent JS-hook
|
|
9764
11129
|
// command is null too. Emit one warning here so the operator sees the cause
|
|
@@ -10112,7 +11477,155 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10112
11477
|
} else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
|
|
10113
11478
|
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
|
|
10114
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
|
+
);
|
|
10115
11627
|
}
|
|
11628
|
+
// ── end hooksConfig.enabled check ────────────────────────────────────────
|
|
10116
11629
|
|
|
10117
11630
|
// Compute the update-banner hook command alongside the others so
|
|
10118
11631
|
// installAllRuntimes can register it at finalize time when the user opts
|
|
@@ -10210,6 +11723,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
10210
11723
|
const isWindsurf = runtime === 'windsurf';
|
|
10211
11724
|
const isTrae = runtime === 'trae';
|
|
10212
11725
|
const isCline = runtime === 'cline';
|
|
11726
|
+
const configIntent = resolveRuntimeConfigIntent(runtime);
|
|
10213
11727
|
|
|
10214
11728
|
if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) {
|
|
10215
11729
|
if (!isGlobal && !forceStatusline) {
|
|
@@ -10262,6 +11776,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
10262
11776
|
}
|
|
10263
11777
|
}
|
|
10264
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
|
+
|
|
10265
11787
|
// Write settings when runtime supports settings.json.
|
|
10266
11788
|
// #3002 CR: defense-in-depth — re-run validateHookFields right before
|
|
10267
11789
|
// serialization. The push-site guards above already skip null-command
|
|
@@ -10269,17 +11791,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
10269
11791
|
// {type: 'command', command: null} items that the runtime hook schema
|
|
10270
11792
|
// rejects at parse time. validateHookFields filters those out so the file
|
|
10271
11793
|
// we write is always schema-valid.
|
|
10272
|
-
if (
|
|
11794
|
+
if (configIntent.writesSharedSettings) {
|
|
10273
11795
|
writeSettings(settingsPath, validateHookFields(settings));
|
|
10274
11796
|
}
|
|
10275
11797
|
|
|
10276
11798
|
// Configure OpenCode permissions
|
|
10277
|
-
if (
|
|
11799
|
+
if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
|
|
10278
11800
|
configureOpencodePermissions(isGlobal, configDir);
|
|
10279
11801
|
}
|
|
10280
11802
|
|
|
10281
11803
|
// Configure Kilo permissions
|
|
10282
|
-
if (
|
|
11804
|
+
if (configIntent.finishPermissionWriter === 'kilo') {
|
|
10283
11805
|
configureKiloPermissions(isGlobal, configDir);
|
|
10284
11806
|
}
|
|
10285
11807
|
|
|
@@ -10623,7 +12145,7 @@ function promptLocation(runtimes) {
|
|
|
10623
12145
|
});
|
|
10624
12146
|
|
|
10625
12147
|
const pathExamples = runtimes.map(r => {
|
|
10626
|
-
const globalPath =
|
|
12148
|
+
const globalPath = getGlobalConfigDir(r, explicitConfigDir);
|
|
10627
12149
|
return globalPath.replace(os.homedir(), '~');
|
|
10628
12150
|
}).join(', ');
|
|
10629
12151
|
|
|
@@ -10995,8 +12517,10 @@ module.exports = {
|
|
|
10995
12517
|
normalizeAgentBodyForRuntime,
|
|
10996
12518
|
yamlIdentifier,
|
|
10997
12519
|
computePathPrefix,
|
|
12520
|
+
applyRuntimeContentRewritesInPlace,
|
|
10998
12521
|
getCodexSkillAdapterHeader,
|
|
10999
12522
|
convertClaudeCommandToCursorSkill,
|
|
12523
|
+
convertClaudeCommandToCursorCommand,
|
|
11000
12524
|
convertClaudeAgentToCursorAgent,
|
|
11001
12525
|
convertClaudeToGeminiMarkdown,
|
|
11002
12526
|
convertSlashCommandsToGeminiMentions,
|
|
@@ -11004,6 +12528,8 @@ module.exports = {
|
|
|
11004
12528
|
convertClaudeToGeminiAgent,
|
|
11005
12529
|
convertClaudeAgentToCodexAgent,
|
|
11006
12530
|
generateCodexAgentToml,
|
|
12531
|
+
generateCodexSkillMetadataYaml,
|
|
12532
|
+
writeCodexSkillMetadataFiles,
|
|
11007
12533
|
generateCodexConfigBlock,
|
|
11008
12534
|
stripGsdFromCodexConfig,
|
|
11009
12535
|
migrateCodexHooksMapFormat,
|
|
@@ -11027,12 +12553,17 @@ module.exports = {
|
|
|
11027
12553
|
convertClaudeCommandToCodexSkill,
|
|
11028
12554
|
convertClaudeToOpencodeFrontmatter,
|
|
11029
12555
|
convertClaudeToKiloFrontmatter,
|
|
12556
|
+
convertClaudeCommandToOpencodeSkill,
|
|
12557
|
+
convertClaudeCommandToKiloSkill,
|
|
11030
12558
|
configureOpencodePermissions,
|
|
11031
12559
|
neutralizeAgentReferences,
|
|
12560
|
+
// #768 — Claude Code permissions pre-population
|
|
12561
|
+
mergeClaudePermissions,
|
|
12562
|
+
GSD_CLAUDE_ALLOW_PERMISSIONS,
|
|
12563
|
+
GSD_CLAUDE_DENY_PERMISSIONS,
|
|
11032
12564
|
GSD_CODEX_MARKER,
|
|
11033
12565
|
CODEX_AGENT_SANDBOX,
|
|
11034
12566
|
getDirName,
|
|
11035
|
-
getGlobalDir,
|
|
11036
12567
|
getConfigDirFromHome,
|
|
11037
12568
|
resolveKiloConfigPath,
|
|
11038
12569
|
configureKiloPermissions,
|
|
@@ -11045,6 +12576,9 @@ module.exports = {
|
|
|
11045
12576
|
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
|
|
11046
12577
|
mergeCopilotInstructions,
|
|
11047
12578
|
stripGsdFromCopilotInstructions,
|
|
12579
|
+
GSD_COPILOT_HOOK_FILE,
|
|
12580
|
+
buildCopilotHookConfig,
|
|
12581
|
+
writeCopilotHookConfig,
|
|
11048
12582
|
convertClaudeToAntigravityContent,
|
|
11049
12583
|
convertClaudeCommandToAntigravitySkill,
|
|
11050
12584
|
convertClaudeAgentToAntigravityAgent,
|
|
@@ -11061,9 +12595,27 @@ module.exports = {
|
|
|
11061
12595
|
convertClaudeAgentToTraeAgent,
|
|
11062
12596
|
convertClaudeToCodebuddyMarkdown,
|
|
11063
12597
|
convertClaudeCommandToCodebuddySkill,
|
|
12598
|
+
convertClaudeCommandToCodebuddyCommand,
|
|
11064
12599
|
convertClaudeAgentToCodebuddyAgent,
|
|
11065
12600
|
convertClaudeToCliineMarkdown,
|
|
12601
|
+
convertClaudeCommandToClineSkill,
|
|
11066
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,
|
|
11067
12619
|
writeManifest,
|
|
11068
12620
|
saveLocalPatches,
|
|
11069
12621
|
reportLocalPatches,
|
|
@@ -11092,11 +12644,16 @@ module.exports = {
|
|
|
11092
12644
|
rewriteLegacyCodexHookBlock,
|
|
11093
12645
|
buildCodexHookWindowsShimIR,
|
|
11094
12646
|
ensureCodexHooksJsonSessionStart,
|
|
12647
|
+
ensureCodexHooksJsonEvent,
|
|
12648
|
+
removeCodexHooksJsonEvent,
|
|
12649
|
+
reconcileCodexHooksJsonEvent,
|
|
11095
12650
|
readGsdCommandNames,
|
|
11096
12651
|
installRuntimeArtifacts,
|
|
12652
|
+
installOpencodeFamilySkills,
|
|
11097
12653
|
uninstallRuntimeArtifacts,
|
|
11098
12654
|
parseConfigDirFromArgs,
|
|
11099
12655
|
cleanupLegacyGsdCc,
|
|
12656
|
+
_applyRuntimeRewrites,
|
|
11100
12657
|
};
|
|
11101
12658
|
|
|
11102
12659
|
// Main logic — only run when not loaded as a module for testing
|
|
@@ -11123,7 +12680,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
|
|
|
11123
12680
|
console.error('Usage: node install.js --skills-root <runtime>');
|
|
11124
12681
|
process.exit(1);
|
|
11125
12682
|
}
|
|
11126
|
-
const globalDir =
|
|
12683
|
+
const globalDir = getGlobalConfigDir(runtimeArg, null);
|
|
11127
12684
|
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
|
|
11128
12685
|
// Other runtimes use a flat skills/ root.
|
|
11129
12686
|
const skillsRoot = runtimeArg === 'hermes'
|