@opengsd/gsd-core 1.4.4 → 1.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -3
- package/agents/gsd-code-fixer.md +3 -2
- package/agents/gsd-debug-session-manager.md +2 -1
- package/agents/gsd-debugger.md +4 -3
- package/agents/gsd-executor.md +17 -16
- package/agents/gsd-intel-updater.md +38 -41
- package/agents/gsd-phase-researcher.md +8 -8
- package/agents/gsd-plan-checker.md +23 -13
- package/agents/gsd-planner.md +32 -188
- package/agents/gsd-project-researcher.md +5 -4
- package/agents/gsd-research-synthesizer.md +2 -1
- package/agents/gsd-ui-researcher.md +2 -1
- package/agents/gsd-verifier.md +12 -11
- package/bin/install.js +965 -1486
- package/commands/gsd/autonomous.md +5 -1
- package/commands/gsd/ns-manage.md +8 -1
- package/commands/gsd/ns-project.md +5 -0
- package/commands/gsd/ns-review.md +4 -1
- package/commands/gsd/ns-workflow.md +7 -1
- package/commands/gsd/plan-review-convergence.md +5 -4
- package/commands/gsd/surface.md +12 -5
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +198 -101
- package/gsd-core/bin/gsd_run +20 -0
- package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
- package/gsd-core/bin/lib/capability-registry.cjs +2234 -0
- package/gsd-core/bin/lib/capability-state.cjs +336 -0
- package/gsd-core/bin/lib/check-command-router.cjs +133 -2
- package/gsd-core/bin/lib/cli-exit.cjs +22 -3
- package/gsd-core/bin/lib/config-loader.cjs +716 -0
- package/gsd-core/bin/lib/configuration.cjs +4 -34
- package/gsd-core/bin/lib/core-utils.cjs +198 -0
- package/gsd-core/bin/lib/core.cjs +107 -1817
- package/gsd-core/bin/lib/edge-probe.cjs +173 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
- package/gsd-core/bin/lib/federated-config.cjs +182 -0
- package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
- package/gsd-core/bin/lib/init.cjs +58 -12
- package/gsd-core/bin/lib/install-profiles.cjs +157 -3
- package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
- package/gsd-core/bin/lib/intel.cjs +3 -3
- package/gsd-core/bin/lib/io.cjs +222 -0
- package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
- package/gsd-core/bin/lib/model-resolver.cjs +426 -0
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
- package/gsd-core/bin/lib/phase-id.cjs +215 -0
- package/gsd-core/bin/lib/phase-locator.cjs +148 -0
- package/gsd-core/bin/lib/phase.cjs +17 -0
- package/gsd-core/bin/lib/probe-core.cjs +257 -0
- package/gsd-core/bin/lib/profile-pipeline.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
- package/gsd-core/bin/lib/roadmap.cjs +44 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +92 -95
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +68 -29
- package/gsd-core/bin/lib/runtime-homes.cjs +163 -87
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +2 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
- package/gsd-core/bin/lib/state-document.cjs +8 -0
- package/gsd-core/bin/lib/state.cjs +114 -2
- package/gsd-core/bin/lib/surface.cjs +66 -14
- package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
- package/gsd-core/bin/lib/update-context.cjs +4 -1
- package/gsd-core/bin/lib/verify.cjs +104 -1
- package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
- package/gsd-core/bin/shared/model-catalog.json +11 -6
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -1
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
- package/gsd-core/references/edge-probe.md +261 -0
- package/gsd-core/references/planner-antipatterns.md +41 -0
- package/gsd-core/references/planner-guidance.md +186 -0
- package/gsd-core/references/planner-reviews.md +5 -2
- package/gsd-core/templates/phase-prompt.md +7 -7
- package/gsd-core/templates/project.md +19 -2
- package/gsd-core/templates/spec.md +12 -0
- package/gsd-core/templates/summary-complex.md +1 -0
- package/gsd-core/templates/summary-minimal.md +1 -0
- package/gsd-core/templates/summary-standard.md +1 -0
- package/gsd-core/templates/summary.md +1 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +1 -1
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +1 -1
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous.md +111 -51
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +6 -4
- package/gsd-core/workflows/code-review.md +53 -17
- package/gsd-core/workflows/complete-milestone.md +11 -5
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +8 -1
- package/gsd-core/workflows/do.md +1 -1
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +4 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
- package/gsd-core/workflows/execute-phase.md +8 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/explore.md +1 -1
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/help/modes/full.md +1 -1
- package/gsd-core/workflows/import.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +1 -1
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +9 -1
- package/gsd-core/workflows/new-project.md +9 -1
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
- package/gsd-core/workflows/plan-phase.md +65 -28
- package/gsd-core/workflows/plan-review-convergence.md +60 -33
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +2 -2
- package/gsd-core/workflows/remove-phase.md +1 -1
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +1 -1
- package/gsd-core/workflows/review.md +1 -1
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +7 -7
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/ship.md +8 -1
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +130 -1
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/thread.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +1 -1
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +1 -1
- package/gsd-core/workflows/update.md +2 -2
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +8 -1
- package/package.json +11 -3
- package/scripts/base64-scan.sh +1 -1
- package/scripts/changeset/cli.cjs +8 -1
- package/scripts/changeset/lint.cjs +38 -2
- package/scripts/ci-test-scope.cjs +21 -10
- package/scripts/gen-capability-registry.cjs +2293 -0
- package/scripts/gen-loop-host-contract.cjs +471 -0
- package/scripts/lib/allowlist-ratchet.cjs +101 -1
- package/scripts/lint-regression-test-names.allowlist.json +269 -0
- package/scripts/lint-regression-test-names.cjs +117 -0
- package/scripts/lint-test-file-count.allowlist.json +25 -4
- package/scripts/lint-windows-test-portability.cjs +178 -0
- package/scripts/prompt-injection-scan.sh +4 -4
- package/scripts/research-profiles.cjs +10 -10
- package/scripts/run-tests.cjs +133 -29
- package/scripts/secret-scan.sh +3 -3
- package/scripts/sync-next-version.cjs +133 -0
- package/scripts/sync-runtime-launcher.cjs +21 -5
- package/scripts/update-size-baseline.cjs +68 -0
- package/scripts/workflow-policy.cjs +42 -9
- package/scripts/workflow-size.cjs +90 -0
- package/scripts/run-cross-platform-tests.cjs +0 -67
package/bin/install.js
CHANGED
|
@@ -31,12 +31,24 @@ const {
|
|
|
31
31
|
const {
|
|
32
32
|
resolveAntigravityGlobalDir,
|
|
33
33
|
getGlobalConfigDir,
|
|
34
|
+
getGlobalSkillsBase,
|
|
34
35
|
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
|
35
36
|
const {
|
|
36
37
|
applyWorktreeBaseRef,
|
|
37
38
|
readBaseRefFromSettings,
|
|
38
39
|
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
|
|
39
|
-
const {
|
|
40
|
+
const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
|
41
|
+
// Canonical set of hook files shipped to users. Imported here so writeManifest()
|
|
42
|
+
// records exactly the same set that build-hooks.js copies to hooks/dist/, making
|
|
43
|
+
// the manifest and the installed hooks/ dir structurally identical. Avoids the
|
|
44
|
+
// prefix/extension-regex approach that missed managed-hooks-registry.cjs (#941).
|
|
45
|
+
const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
|
|
46
|
+
const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
|
|
47
|
+
|
|
48
|
+
// ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated module.
|
|
49
|
+
// bin/install.js re-exports everything from hooksSurface so existing callers
|
|
50
|
+
// (require('../bin/install.js').writeCursorHooksJson etc.) continue to work.
|
|
51
|
+
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
40
52
|
|
|
41
53
|
/**
|
|
42
54
|
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
|
|
@@ -323,6 +335,13 @@ const {
|
|
|
323
335
|
stageAgentsForProfile,
|
|
324
336
|
stageSkillsForRuntimeAsSkills,
|
|
325
337
|
} = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
|
|
338
|
+
// ADR-857 phase 4c: load capability registry (optional; missing → falls back to undefined)
|
|
339
|
+
let _capabilityRegistry;
|
|
340
|
+
try {
|
|
341
|
+
_capabilityRegistry = require(path.join(_gsdLibDir, 'capability-registry.cjs'));
|
|
342
|
+
} catch (_) {
|
|
343
|
+
_capabilityRegistry = undefined;
|
|
344
|
+
}
|
|
326
345
|
const {
|
|
327
346
|
applyInstallerMigrationPlan,
|
|
328
347
|
discoverInstallerMigrations,
|
|
@@ -348,23 +367,6 @@ const {
|
|
|
348
367
|
const args = process.argv.slice(2);
|
|
349
368
|
const hasGlobal = args.includes('--global') || args.includes('-g');
|
|
350
369
|
const hasLocal = args.includes('--local') || args.includes('-l');
|
|
351
|
-
const hasOpencode = args.includes('--opencode');
|
|
352
|
-
const hasClaude = args.includes('--claude');
|
|
353
|
-
const hasGemini = args.includes('--gemini');
|
|
354
|
-
const hasKilo = args.includes('--kilo');
|
|
355
|
-
const hasCodex = args.includes('--codex');
|
|
356
|
-
const hasCopilot = args.includes('--copilot');
|
|
357
|
-
const hasAntigravity = args.includes('--antigravity');
|
|
358
|
-
const hasCursor = args.includes('--cursor');
|
|
359
|
-
const hasWindsurf = args.includes('--windsurf');
|
|
360
|
-
const hasAugment = args.includes('--augment');
|
|
361
|
-
const hasTrae = args.includes('--trae');
|
|
362
|
-
const hasQwen = args.includes('--qwen');
|
|
363
|
-
const hasHermes = args.includes('--hermes');
|
|
364
|
-
const hasCodebuddy = args.includes('--codebuddy');
|
|
365
|
-
const hasCline = args.includes('--cline');
|
|
366
|
-
const hasBoth = args.includes('--both'); // Legacy flag, keeps working
|
|
367
|
-
const hasAll = args.includes('--all');
|
|
368
370
|
const hasUninstall = args.includes('--uninstall') || args.includes('-u');
|
|
369
371
|
const hasSkillsRoot = args.includes('--skills-root');
|
|
370
372
|
const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1';
|
|
@@ -391,30 +393,37 @@ if (hasMinimal && _profileArgRaw) {
|
|
|
391
393
|
process.exit(1);
|
|
392
394
|
}
|
|
393
395
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
if (
|
|
405
|
-
if (
|
|
406
|
-
if (
|
|
407
|
-
if (
|
|
408
|
-
if (
|
|
409
|
-
if (
|
|
410
|
-
if (
|
|
411
|
-
if (
|
|
412
|
-
if (
|
|
413
|
-
if (
|
|
414
|
-
if (
|
|
415
|
-
if (
|
|
396
|
+
function selectRuntimesFromArgs(runtimeArgs) {
|
|
397
|
+
if (runtimeArgs.includes('--all')) {
|
|
398
|
+
return ['claude', 'kimi', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline'];
|
|
399
|
+
}
|
|
400
|
+
if (runtimeArgs.includes('--both')) {
|
|
401
|
+
return ['claude', 'opencode'];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const selected = [];
|
|
405
|
+
if (runtimeArgs.includes('--claude')) selected.push('claude');
|
|
406
|
+
if (runtimeArgs.includes('--opencode')) selected.push('opencode');
|
|
407
|
+
if (runtimeArgs.includes('--gemini')) selected.push('gemini');
|
|
408
|
+
if (runtimeArgs.includes('--kilo')) selected.push('kilo');
|
|
409
|
+
if (runtimeArgs.includes('--codex')) selected.push('codex');
|
|
410
|
+
if (runtimeArgs.includes('--copilot')) selected.push('copilot');
|
|
411
|
+
if (runtimeArgs.includes('--antigravity')) selected.push('antigravity');
|
|
412
|
+
if (runtimeArgs.includes('--cursor')) selected.push('cursor');
|
|
413
|
+
if (runtimeArgs.includes('--windsurf') || runtimeArgs.includes('--devin-desktop')) selected.push('windsurf');
|
|
414
|
+
if (runtimeArgs.includes('--augment')) selected.push('augment');
|
|
415
|
+
if (runtimeArgs.includes('--trae')) selected.push('trae');
|
|
416
|
+
if (runtimeArgs.includes('--qwen')) selected.push('qwen');
|
|
417
|
+
if (runtimeArgs.includes('--hermes')) selected.push('hermes');
|
|
418
|
+
if (runtimeArgs.includes('--kimi')) selected.push('kimi');
|
|
419
|
+
if (runtimeArgs.includes('--codebuddy')) selected.push('codebuddy');
|
|
420
|
+
if (runtimeArgs.includes('--cline')) selected.push('cline');
|
|
421
|
+
return selected;
|
|
416
422
|
}
|
|
417
423
|
|
|
424
|
+
// Runtime selection - can be set by flags or interactive prompt
|
|
425
|
+
let selectedRuntimes = selectRuntimesFromArgs(args);
|
|
426
|
+
|
|
418
427
|
// WSL + Windows Node.js detection
|
|
419
428
|
// When Windows-native Node runs on WSL, os.homedir() and path.join() produce
|
|
420
429
|
// backslash paths that don't resolve correctly on the Linux filesystem.
|
|
@@ -456,13 +465,14 @@ function getDirName(runtime) {
|
|
|
456
465
|
if (runtime === 'gemini') return '.gemini';
|
|
457
466
|
if (runtime === 'kilo') return '.kilo';
|
|
458
467
|
if (runtime === 'codex') return '.codex';
|
|
459
|
-
if (runtime === 'antigravity') return '.
|
|
468
|
+
if (runtime === 'antigravity') return '.agents';
|
|
460
469
|
if (runtime === 'cursor') return '.cursor';
|
|
461
|
-
if (runtime === 'windsurf') return '.
|
|
470
|
+
if (runtime === 'windsurf') return '.devin';
|
|
462
471
|
if (runtime === 'augment') return '.augment';
|
|
463
472
|
if (runtime === 'trae') return '.trae';
|
|
464
473
|
if (runtime === 'qwen') return '.qwen';
|
|
465
474
|
if (runtime === 'hermes') return '.hermes';
|
|
475
|
+
if (runtime === 'kimi') return '.kimi-code';
|
|
466
476
|
if (runtime === 'codebuddy') return '.codebuddy';
|
|
467
477
|
if (runtime === 'cline') return '.cline';
|
|
468
478
|
return '.claude';
|
|
@@ -490,7 +500,7 @@ function getConfigDirFromHome(runtime, isGlobal) {
|
|
|
490
500
|
if (runtime === 'kilo') return "'.config', 'kilo'";
|
|
491
501
|
if (runtime === 'codex') return "'.codex'";
|
|
492
502
|
if (runtime === 'antigravity') {
|
|
493
|
-
if (!isGlobal) return "'.
|
|
503
|
+
if (!isGlobal) return "'.agents'";
|
|
494
504
|
const antigravityDir = resolveAntigravityGlobalDir();
|
|
495
505
|
const rel = path.relative(os.homedir(), antigravityDir);
|
|
496
506
|
const segments = rel.split(path.sep).filter(Boolean);
|
|
@@ -509,9 +519,17 @@ function getConfigDirFromHome(runtime, isGlobal) {
|
|
|
509
519
|
if (runtime === 'hermes') return "'.hermes'";
|
|
510
520
|
if (runtime === 'codebuddy') return "'.codebuddy'";
|
|
511
521
|
if (runtime === 'cline') return "'.cline'";
|
|
522
|
+
if (runtime === 'kimi') return "'.config', 'agents'";
|
|
512
523
|
return "'.claude'";
|
|
513
524
|
}
|
|
514
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Compatibility seam for tests and older installer consumers.
|
|
528
|
+
* Runtime home resolution now lives in runtime-homes.cjs.
|
|
529
|
+
*/
|
|
530
|
+
function getGlobalDir(runtime, explicitDir = null) {
|
|
531
|
+
return getGlobalConfigDir(runtime, explicitDir);
|
|
532
|
+
}
|
|
515
533
|
const banner = '\n' +
|
|
516
534
|
cyan + ' ██████╗ ███████╗██████╗\n' +
|
|
517
535
|
' ██╔════╝ ██╔════╝██╔══██╗\n' +
|
|
@@ -523,7 +541,7 @@ const banner = '\n' +
|
|
|
523
541
|
' GSD Core ' + dim + 'v' + pkg.version + reset + '\n' +
|
|
524
542
|
' Git. Ship. Done.\n' +
|
|
525
543
|
' A meta-prompting, context engineering and spec-driven\n' +
|
|
526
|
-
' development workflows for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\n';
|
|
544
|
+
' development workflows for Claude Code, OpenCode, Gemini, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\n';
|
|
527
545
|
|
|
528
546
|
// Pure seam: parse --config-dir / -c from an arbitrary args array.
|
|
529
547
|
// Returns the path string, '' for an empty equals-form value, or null when the
|
|
@@ -580,7 +598,7 @@ if (hasUninstall) {
|
|
|
580
598
|
|
|
581
599
|
// Show help if requested
|
|
582
600
|
if (hasHelp) {
|
|
583
|
-
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 — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all 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`);
|
|
601
|
+
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}--kimi${reset} Install for Kimi CLI 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 — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all 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 Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\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 / KIMI_CONFIG_DIR / 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 Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
|
|
584
602
|
process.exit(0);
|
|
585
603
|
}
|
|
586
604
|
|
|
@@ -609,177 +627,22 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
|
|
|
609
627
|
return `${resolvedTarget}/`;
|
|
610
628
|
}
|
|
611
629
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
* Non-Homebrew installs (NVM, system node, Windows, etc.) are returned as-is.
|
|
626
|
-
*/
|
|
627
|
-
function normalizeNodePath(execPath) {
|
|
628
|
-
if (!execPath) return execPath;
|
|
629
|
-
// Intel Homebrew: /usr/local/Cellar/node/<version>/bin/node
|
|
630
|
-
// or /usr/local/Cellar/node@20/<version>/bin/node
|
|
631
|
-
if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
|
|
632
|
-
return '/usr/local/bin/node';
|
|
633
|
-
}
|
|
634
|
-
// Apple Silicon Homebrew: /opt/homebrew/Cellar/node/<version>/bin/node
|
|
635
|
-
// or /opt/homebrew/Cellar/node@18/<version>/bin/node
|
|
636
|
-
if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
|
|
637
|
-
return '/opt/homebrew/bin/node';
|
|
638
|
-
}
|
|
639
|
-
return execPath;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Resolve the absolute path to the node binary running the installer.
|
|
644
|
-
* Used as the runner for .js hooks so they execute in GUI/minimal-PATH
|
|
645
|
-
* runtimes (Gemini, Antigravity, Codex CLIs launched from a Finder
|
|
646
|
-
* shortcut etc.) where bare `node` is not on `/usr/bin:/bin:/usr/sbin:/sbin`
|
|
647
|
-
* and the hook would fail with `node: command not found` (#2979).
|
|
648
|
-
*
|
|
649
|
-
* Returns a forward-slash-normalized, double-quoted path so the emitted
|
|
650
|
-
* command is shell-safe across POSIX and Windows. `process.execPath`
|
|
651
|
-
* gives the absolute path of the node binary actively running the
|
|
652
|
-
* installer — that is the version the user just installed under, and
|
|
653
|
-
* the right default runtime for hooks invoked under the same install.
|
|
654
|
-
*
|
|
655
|
-
* When `process.execPath` is a versioned Homebrew Cellar path, the stable
|
|
656
|
-
* Homebrew symlink is returned instead to survive `brew upgrade node` (#3181).
|
|
657
|
-
*/
|
|
658
|
-
function resolveNodeRunner() {
|
|
659
|
-
const execPath = typeof process.execPath === 'string' ? process.execPath : '';
|
|
660
|
-
if (!execPath) return null;
|
|
661
|
-
const stablePath = normalizeNodePath(execPath);
|
|
662
|
-
// JSON.stringify produces a properly escaped double-quoted shell token,
|
|
663
|
-
// safe for paths containing spaces or unusual characters.
|
|
664
|
-
return JSON.stringify(stablePath.replace(/\\/g, '/'));
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* Rewrite legacy `node .../gsd-*.js` command strings in settings.hooks to use
|
|
669
|
-
* the absolute Node binary path (#2979 follow-up: CR feedback on #3002).
|
|
670
|
-
*
|
|
671
|
-
* The original #2979 fix only emitted absolute paths for *newly registered*
|
|
672
|
-
* hooks. Pre-existing entries kept their bare `node ` prefix on reinstall,
|
|
673
|
-
* which left them broken under minimal-PATH GUI runtimes — exactly the
|
|
674
|
-
* failure mode the original fix was meant to close. This walker normalizes
|
|
675
|
-
* any managed-hook entry whose command starts with bare `node ` to
|
|
676
|
-
* `<absoluteRunner> <script>` while leaving non-managed and non-bare-node
|
|
677
|
-
* entries (user-authored hooks, shell scripts, etc.) untouched.
|
|
678
|
-
*
|
|
679
|
-
* Returns true if any entry was rewritten.
|
|
680
|
-
*/
|
|
681
|
-
function resolveBashRunner(opts) {
|
|
682
|
-
const platform = (opts && opts.platform) || process.platform;
|
|
683
|
-
if (platform !== 'win32') return 'bash';
|
|
684
|
-
|
|
685
|
-
const env = (opts && opts.env) || process.env;
|
|
686
|
-
const exists = (opts && opts.existsSync) || fs.existsSync;
|
|
687
|
-
const candidates = [];
|
|
688
|
-
if (env.GSD_BASH_PATH) candidates.push(env.GSD_BASH_PATH);
|
|
689
|
-
if (env.ProgramFiles) candidates.push(path.win32.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'));
|
|
690
|
-
if (env['ProgramFiles(x86)']) candidates.push(path.win32.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'));
|
|
691
|
-
if (env.SystemDrive) {
|
|
692
|
-
candidates.push(path.win32.join(env.SystemDrive, 'Program Files', 'Git', 'bin', 'bash.exe'));
|
|
693
|
-
candidates.push(path.win32.join(env.SystemDrive, 'Program Files (x86)', 'Git', 'bin', 'bash.exe'));
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
for (const candidate of candidates) {
|
|
697
|
-
if (candidate && exists(candidate)) {
|
|
698
|
-
return JSON.stringify(candidate.replace(/\\/g, '/'));
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
return null;
|
|
702
|
-
}
|
|
630
|
+
// normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are
|
|
631
|
+
// now owned by the runtime-hooks-surface module. Import them here so
|
|
632
|
+
// install.js callers continue to work and so there is a single implementation
|
|
633
|
+
// of these helpers.
|
|
634
|
+
const normalizeNodePath = hooksSurface.normalizeNodePath;
|
|
635
|
+
const resolveNodeRunner = hooksSurface.resolveNodeRunner;
|
|
636
|
+
const resolveBashRunner = hooksSurface.resolveBashRunner;
|
|
637
|
+
// referencesHook: pure predicate over hook entry objects, shared between
|
|
638
|
+
// install() and finishInstall() (ADR-857 phase 5f-1b).
|
|
639
|
+
const referencesHook = hooksSurface.referencesHook;
|
|
640
|
+
// applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed
|
|
641
|
+
// hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
|
|
642
|
+
const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
|
|
703
643
|
|
|
704
644
|
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
|
705
|
-
|
|
706
|
-
if (!opts) opts = {};
|
|
707
|
-
const platform = opts.platform || process.platform;
|
|
708
|
-
let changed = false;
|
|
709
|
-
for (const entries of Object.values(settings.hooks)) {
|
|
710
|
-
if (!Array.isArray(entries)) continue;
|
|
711
|
-
for (const entry of entries) {
|
|
712
|
-
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
713
|
-
for (const h of entry.hooks) {
|
|
714
|
-
if (!h || typeof h.command !== 'string') continue;
|
|
715
|
-
let trimmed = h.command.trim();
|
|
716
|
-
const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
|
|
717
|
-
if (hadPowerShellCallOperator) {
|
|
718
|
-
trimmed = trimmed.replace(/^&\s+/, '').trim();
|
|
719
|
-
}
|
|
720
|
-
// Match two runner forms:
|
|
721
|
-
// 1. Legacy bare-node form: `node <script>` (#2979/#3002)
|
|
722
|
-
// 2. Cellar-path form: `"/usr/local/Cellar/node/<v>/bin/node" <script>`
|
|
723
|
-
// or `"/opt/homebrew/Cellar/node/<v>/bin/node" <script>` (#3181)
|
|
724
|
-
//
|
|
725
|
-
// Both patterns use the same script-token capture group so the rewrite
|
|
726
|
-
// is uniform. We detect the Cellar form by extracting the runner token
|
|
727
|
-
// and running it through normalizeNodePath.
|
|
728
|
-
//
|
|
729
|
-
// The previous shape used `trimmed.includes(<filename>)` which would
|
|
730
|
-
// false-positive on user-authored hooks whose path merely contained
|
|
731
|
-
// a managed filename as a substring (e.g.
|
|
732
|
-
// /home/me/scripts/wraps-gsd-check-update.js-and-more.js). #3002 CR.
|
|
733
|
-
const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) ||
|
|
734
|
-
trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/);
|
|
735
|
-
if (!m) continue;
|
|
736
|
-
|
|
737
|
-
let runnerToken, scriptToken, scriptPath;
|
|
738
|
-
if (/^node\s+/.test(trimmed)) {
|
|
739
|
-
// bare-node form
|
|
740
|
-
runnerToken = 'node';
|
|
741
|
-
scriptToken = m[1];
|
|
742
|
-
scriptPath = m[2] || m[3] || m[4] || '';
|
|
743
|
-
} else {
|
|
744
|
-
// quoted/unquoted runner form — check whether runner is a Cellar path
|
|
745
|
-
runnerToken = m[1];
|
|
746
|
-
const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
|
|
747
|
-
const stableRunner = normalizeNodePath(runnerPath);
|
|
748
|
-
// Process Cellar paths so they normalize to a stable symlink. On
|
|
749
|
-
// Windows, already-absolute runners still flow through the projection
|
|
750
|
-
// seam because some runtimes need additional wrapper policy while
|
|
751
|
-
// others must stay shell-neutral (#3362, #3413).
|
|
752
|
-
if (stableRunner === runnerPath && platform !== 'win32') continue;
|
|
753
|
-
scriptToken = m[5];
|
|
754
|
-
scriptPath = m[6] || m[7] || m[8] || '';
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// Take the basename — match against MANAGED_HOOK_FILES by exact
|
|
758
|
-
// equality, not substring containment. Handles both forward and
|
|
759
|
-
// backslash separators (Windows).
|
|
760
|
-
if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue;
|
|
761
|
-
|
|
762
|
-
const projectedCommand = projectLegacySettingsHookCommand({
|
|
763
|
-
absoluteRunner,
|
|
764
|
-
scriptPath,
|
|
765
|
-
scriptToken,
|
|
766
|
-
runtime: opts.runtime || 'generic',
|
|
767
|
-
platform,
|
|
768
|
-
});
|
|
769
|
-
if (!projectedCommand) continue;
|
|
770
|
-
|
|
771
|
-
// Skip only when the existing managed command already matches the
|
|
772
|
-
// desired runtime-aware projected shape. This preserves Gemini's
|
|
773
|
-
// required PowerShell prefix while still letting Claude strip stale
|
|
774
|
-
// prefixes on reinstall (#3413).
|
|
775
|
-
if (h.command === projectedCommand) continue;
|
|
776
|
-
|
|
777
|
-
h.command = projectedCommand;
|
|
778
|
-
changed = true;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
return changed;
|
|
645
|
+
return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
|
|
783
646
|
}
|
|
784
647
|
|
|
785
648
|
/**
|
|
@@ -805,22 +668,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
|
|
805
668
|
* @returns {string|null} The toml block to append, or null on missing runner.
|
|
806
669
|
*/
|
|
807
670
|
function buildCodexHookBlock(targetDir, opts) {
|
|
808
|
-
|
|
809
|
-
if (!absoluteRunner) return null;
|
|
810
|
-
const eol = (opts && opts.eol) || '\n';
|
|
811
|
-
const platform = (opts && opts.platform) || process.platform;
|
|
812
|
-
const updateCheckScript = path.resolve(targetDir, 'hooks', 'gsd-check-update.js');
|
|
813
|
-
const commandValue = projectCodexHookTomlCommand({
|
|
814
|
-
absoluteRunner,
|
|
815
|
-
scriptPath: updateCheckScript,
|
|
816
|
-
platform,
|
|
817
|
-
});
|
|
818
|
-
return `${eol}# GSD Hooks${eol}` +
|
|
819
|
-
`[[hooks.SessionStart]]${eol}` +
|
|
820
|
-
`${eol}` +
|
|
821
|
-
`[[hooks.SessionStart.hooks]]${eol}` +
|
|
822
|
-
`type = "command"${eol}` +
|
|
823
|
-
`command = "${commandValue}"${eol}`;
|
|
671
|
+
return hooksSurface.buildCodexHookBlock(targetDir, opts);
|
|
824
672
|
}
|
|
825
673
|
|
|
826
674
|
/**
|
|
@@ -837,45 +685,7 @@ function buildCodexHookBlock(targetDir, opts) {
|
|
|
837
685
|
* @returns {{ content: string, changed: boolean }}
|
|
838
686
|
*/
|
|
839
687
|
function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
|
|
840
|
-
|
|
841
|
-
const platform = (opts && opts.platform) || process.platform;
|
|
842
|
-
let changed = false;
|
|
843
|
-
// Match `command = "node <scriptToken>"` lines where scriptToken is
|
|
844
|
-
// either an unquoted path (no spaces) or a toml-escaped quoted path.
|
|
845
|
-
// The whole RHS is a toml-double-quoted string; interior quotes are \".
|
|
846
|
-
// Examples we want to migrate:
|
|
847
|
-
// command = "node /Users/x/.codex/hooks/gsd-check-update.js"
|
|
848
|
-
// command = "node \"/Users/x/.codex/hooks/gsd-check-update.js\""
|
|
849
|
-
// Examples we must leave alone:
|
|
850
|
-
// command = "\"/usr/local/bin/node\" \"/path/to/gsd-check-update.js\"" ← already absolute
|
|
851
|
-
// command = "node /home/me/my-custom.js" ← user-owned filename
|
|
852
|
-
const updated = content.replace(
|
|
853
|
-
/^(command\s*=\s*")node\s+((?:\\"[^"]+\\"|\S+))("\s*)$/gm,
|
|
854
|
-
(full, prefix, scriptToken, suffix) => {
|
|
855
|
-
// Extract the underlying script path from the captured token —
|
|
856
|
-
// either the bare token or the decoded inner content of \"...\".
|
|
857
|
-
const quoted = scriptToken.match(/^\\"([\s\S]+)\\"$/);
|
|
858
|
-
let scriptPath = scriptToken;
|
|
859
|
-
if (quoted) {
|
|
860
|
-
try {
|
|
861
|
-
scriptPath = String(parseTomlValue(`"${quoted[1]}"`, 0).value);
|
|
862
|
-
} catch {
|
|
863
|
-
scriptPath = quoted[1];
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' })) return full;
|
|
867
|
-
const desiredCommand = projectCodexHookTomlCommand({
|
|
868
|
-
absoluteRunner,
|
|
869
|
-
scriptPath,
|
|
870
|
-
platform,
|
|
871
|
-
});
|
|
872
|
-
const currentCommand = `${prefix}${scriptToken}${suffix}`.replace(/^(command\s*=\s*")|("\s*)$/g, '');
|
|
873
|
-
if (currentCommand === desiredCommand) return full;
|
|
874
|
-
changed = true;
|
|
875
|
-
return `${prefix}${desiredCommand}${suffix}`;
|
|
876
|
-
},
|
|
877
|
-
);
|
|
878
|
-
return { content: updated, changed };
|
|
688
|
+
return hooksSurface.rewriteLegacyCodexHookBlock(content, absoluteRunner, opts);
|
|
879
689
|
}
|
|
880
690
|
|
|
881
691
|
/**
|
|
@@ -898,83 +708,7 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
|
|
|
898
708
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
899
709
|
*/
|
|
900
710
|
function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
901
|
-
|
|
902
|
-
const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
|
|
903
|
-
const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
|
|
904
|
-
const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
|
|
905
|
-
const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
|
|
906
|
-
let parsed = {};
|
|
907
|
-
let currentContent = null;
|
|
908
|
-
if (fs.existsSync(hooksJsonPath)) {
|
|
909
|
-
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
|
|
910
|
-
currentContent = raw;
|
|
911
|
-
if (raw.trim()) {
|
|
912
|
-
try {
|
|
913
|
-
parsed = JSON.parse(raw);
|
|
914
|
-
} catch (err) {
|
|
915
|
-
throw new Error(`hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
|
|
920
|
-
|
|
921
|
-
const usesNestedHooksObject =
|
|
922
|
-
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
|
|
923
|
-
const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
|
|
924
|
-
const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
|
|
925
|
-
|
|
926
|
-
let removedLegacy = false;
|
|
927
|
-
const sanitizedEntries = [];
|
|
928
|
-
for (const entry of eventEntries) {
|
|
929
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
930
|
-
const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
931
|
-
if (originalHooks.length === 0) {
|
|
932
|
-
sanitizedEntries.push(entry);
|
|
933
|
-
continue;
|
|
934
|
-
}
|
|
935
|
-
const keptHooks = originalHooks.filter((hook) => {
|
|
936
|
-
const cmd = hook && typeof hook === 'object' ? hook.command : null;
|
|
937
|
-
const managed = isManagedHookCommand(cmd, {
|
|
938
|
-
surface: 'codex-hooks-json',
|
|
939
|
-
includeLegacyAliases: true,
|
|
940
|
-
configDir: targetDir,
|
|
941
|
-
});
|
|
942
|
-
if (managed) removedLegacy = true;
|
|
943
|
-
return !managed;
|
|
944
|
-
});
|
|
945
|
-
if (keptHooks.length === 0) continue;
|
|
946
|
-
const nextEntry = { ...entry, hooks: keptHooks };
|
|
947
|
-
sanitizedEntries.push(nextEntry);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
if (managedCommand) {
|
|
951
|
-
const hookEntry = { type: 'command', command: managedCommand };
|
|
952
|
-
// #772: emit commandWindows so Codex picks the .cmd shim on Windows and
|
|
953
|
-
// the POSIX command on other platforms — without requiring per-OS config
|
|
954
|
-
// regeneration. Sourced from HookHandlerConfig.command_windows field in
|
|
955
|
-
// codex-rs/config/src/hook_config.rs (alias: commandWindows).
|
|
956
|
-
if (commandWindows) hookEntry.commandWindows = commandWindows;
|
|
957
|
-
if (timeout !== undefined) hookEntry.timeout = timeout;
|
|
958
|
-
const newEntry = { hooks: [hookEntry] };
|
|
959
|
-
if (matcher !== undefined) newEntry.matcher = matcher;
|
|
960
|
-
sanitizedEntries.push(newEntry);
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
if (sanitizedEntries.length > 0) {
|
|
964
|
-
hookTable[eventName] = sanitizedEntries;
|
|
965
|
-
} else {
|
|
966
|
-
delete hookTable[eventName];
|
|
967
|
-
}
|
|
968
|
-
if (usesNestedHooksObject) parsed.hooks = hookTable;
|
|
969
|
-
|
|
970
|
-
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
971
|
-
const changed = currentContent !== nextContent;
|
|
972
|
-
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
973
|
-
if (shouldWrite) {
|
|
974
|
-
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
|
|
711
|
+
return hooksSurface.reconcileCodexHooksJsonEvent(targetDir, eventName, opts);
|
|
978
712
|
}
|
|
979
713
|
|
|
980
714
|
/**
|
|
@@ -986,7 +720,7 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
|
986
720
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
987
721
|
*/
|
|
988
722
|
function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
989
|
-
return
|
|
723
|
+
return hooksSurface.reconcileCodexHooksJsonSessionStart(targetDir, opts);
|
|
990
724
|
}
|
|
991
725
|
|
|
992
726
|
/**
|
|
@@ -1019,41 +753,7 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1019
753
|
* @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null}
|
|
1020
754
|
*/
|
|
1021
755
|
function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
|
|
1022
|
-
|
|
1023
|
-
// absoluteRunnerToken is JSON-quoted (e.g. '"C:/path/node.exe"'). Unwrap to
|
|
1024
|
-
// get the raw interpreter path for the invocation record and render output.
|
|
1025
|
-
let interpreter;
|
|
1026
|
-
try {
|
|
1027
|
-
interpreter = JSON.parse(absoluteRunnerToken);
|
|
1028
|
-
} catch {
|
|
1029
|
-
interpreter = absoluteRunnerToken;
|
|
1030
|
-
}
|
|
1031
|
-
// Normalise to forward slashes for cross-shell safety (same as other Windows
|
|
1032
|
-
// hook path normalisations in this codebase).
|
|
1033
|
-
const targetAbs = scriptAbsPath.replace(/\\/g, '/');
|
|
1034
|
-
const scriptQuoted = JSON.stringify(targetAbs);
|
|
1035
|
-
// .cmd shim lives alongside the .js file, replacing the extension.
|
|
1036
|
-
const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
|
|
1037
|
-
// The hook command written to hooks.json is just the .cmd path (double-quoted
|
|
1038
|
-
// for spaces-in-path safety). cmd.exe executes .cmd files natively via
|
|
1039
|
-
// CreateProcess — no runner prefix required.
|
|
1040
|
-
const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
|
|
1041
|
-
const runnerQuoted = JSON.stringify(interpreter);
|
|
1042
|
-
return {
|
|
1043
|
-
invocation: { interpreter, target: scriptAbsPath },
|
|
1044
|
-
cmdPath,
|
|
1045
|
-
hookCommand,
|
|
1046
|
-
// Typed fields for IR-level assertions (CONTRIBUTING.md L558-L565).
|
|
1047
|
-
// These describe the render semantics in a structured way so tests can
|
|
1048
|
-
// assert on the generator contract without coupling to rendered text.
|
|
1049
|
-
eol: { cmd: '\r\n' }, // CRLF — canonical for cmd.exe .cmd files
|
|
1050
|
-
passthroughArgs: true, // the shim forwards all args via %*
|
|
1051
|
-
render: {
|
|
1052
|
-
// Use CRLF line endings for strict cmd.exe compatibility (LF-only
|
|
1053
|
-
// .cmd files work in modern Windows but CRLF is the canonical format).
|
|
1054
|
-
cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
|
|
1055
|
-
},
|
|
1056
|
-
};
|
|
756
|
+
return hooksSurface.buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken);
|
|
1057
757
|
}
|
|
1058
758
|
|
|
1059
759
|
/**
|
|
@@ -1084,69 +784,7 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
|
|
|
1084
784
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
1085
785
|
*/
|
|
1086
786
|
function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
1087
|
-
|
|
1088
|
-
const absoluteRunner = opts.absoluteRunner || null;
|
|
1089
|
-
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
1090
|
-
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1091
|
-
|
|
1092
|
-
// Normalize backslashes to forward slashes so isManagedHookCommand can
|
|
1093
|
-
// match stored commands against configDir on Windows CI runners where
|
|
1094
|
-
// path.resolve returns backslash paths but the stored command may use
|
|
1095
|
-
// forward slashes (or vice versa). Forward-slash paths are always valid on
|
|
1096
|
-
// Windows for both Node.js and Codex, so this normalization is safe for all
|
|
1097
|
-
// platforms. (#772 — same fix applied to ensureCodexHooksJsonEvent.)
|
|
1098
|
-
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
|
1099
|
-
|
|
1100
|
-
// #772: compute the Windows .cmd shim path cross-platform so that
|
|
1101
|
-
// `commandWindows` can be emitted in hooks.json regardless of the host OS.
|
|
1102
|
-
// The .cmd path is always the .js script path with extension replaced.
|
|
1103
|
-
const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
|
|
1104
|
-
|
|
1105
|
-
let managedCommand;
|
|
1106
|
-
if (platform === 'win32') {
|
|
1107
|
-
// #3426 fix: on Windows, write a .cmd shim and use its path as the hook
|
|
1108
|
-
// command. This avoids the MSYS bash.exe POSIX-exec failure when Codex's
|
|
1109
|
-
// hook dispatcher tries to run node.exe through the Git Bash exec layer.
|
|
1110
|
-
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
|
|
1111
|
-
if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1112
|
-
try {
|
|
1113
|
-
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
|
|
1114
|
-
} catch (shimWriteErr) {
|
|
1115
|
-
// Shim write failed — do NOT fall back to the old "node.exe script.js"
|
|
1116
|
-
// command. That form triggers the `bash.exe: cannot execute binary file`
|
|
1117
|
-
// failure that #3426 exists to fix, so a silent fallback would silently
|
|
1118
|
-
// restore the original bug. Instead: warn loudly and skip the registration
|
|
1119
|
-
// for this runtime so the user sees an actionable message rather than a
|
|
1120
|
-
// successful install that fails at hook-dispatch time.
|
|
1121
|
-
const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
|
|
1122
|
-
console.warn(
|
|
1123
|
-
` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` +
|
|
1124
|
-
`Fix the write error (permissions? disk full?) and re-run the installer. ` +
|
|
1125
|
-
`Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`,
|
|
1126
|
-
);
|
|
1127
|
-
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1128
|
-
}
|
|
1129
|
-
managedCommand = shimIR.hookCommand;
|
|
1130
|
-
} else {
|
|
1131
|
-
managedCommand = projectManagedHookCommand({
|
|
1132
|
-
absoluteRunner,
|
|
1133
|
-
scriptPath,
|
|
1134
|
-
runtime: 'codex',
|
|
1135
|
-
platform,
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1140
|
-
|
|
1141
|
-
// #772: emit commandWindows — the .cmd shim path — but ONLY on Windows where
|
|
1142
|
-
// the shim was actually written. On POSIX, commandWindows is omitted to avoid
|
|
1143
|
-
// pointing Windows Codex at a non-existent .cmd file (the shim is only present
|
|
1144
|
-
// when install() ran natively on Windows and wrote it via buildCodexHookWindowsShimIR).
|
|
1145
|
-
const commandWindows = platform === 'win32'
|
|
1146
|
-
? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
|
|
1147
|
-
: undefined;
|
|
1148
|
-
|
|
1149
|
-
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
|
|
787
|
+
return hooksSurface.ensureCodexHooksJsonSessionStart(targetDir, opts);
|
|
1150
788
|
}
|
|
1151
789
|
|
|
1152
790
|
/**
|
|
@@ -1173,50 +811,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1173
811
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
1174
812
|
*/
|
|
1175
813
|
function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
1176
|
-
|
|
1177
|
-
const absoluteRunner = opts.absoluteRunner || null;
|
|
1178
|
-
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
1179
|
-
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1180
|
-
|
|
1181
|
-
// Normalize backslashes to forward slashes so that isManagedHookCommand can
|
|
1182
|
-
// match the stored command against configDir on Windows. path.resolve on
|
|
1183
|
-
// Windows returns backslash paths, but when platform is not 'win32'
|
|
1184
|
-
// (e.g. platform: 'linux' in a test running on a Windows CI runner),
|
|
1185
|
-
// projectManagedHookCommand does not normalize them — producing a mismatch
|
|
1186
|
-
// between the stored command and the configDir-based hook-dir prefix used
|
|
1187
|
-
// for deduplication. Forward-slash paths are always valid on Windows (Node.js
|
|
1188
|
-
// and Codex both accept them), so normalizing here is safe for all platforms.
|
|
1189
|
-
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
|
|
1190
|
-
|
|
1191
|
-
let managedCommand;
|
|
1192
|
-
if (platform === 'win32') {
|
|
1193
|
-
// #3426 fix pattern: on Windows, write a .cmd shim and use its path as the
|
|
1194
|
-
// hook command. The same bash.exe POSIX-exec failure that affects
|
|
1195
|
-
// gsd-check-update.js also affects gsd-context-monitor.js.
|
|
1196
|
-
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
|
|
1197
|
-
if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1198
|
-
try {
|
|
1199
|
-
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
|
|
1200
|
-
} catch (shimWriteErr) {
|
|
1201
|
-
const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
|
|
1202
|
-
console.warn(
|
|
1203
|
-
` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
|
|
1204
|
-
`Fix the write error (permissions? disk full?) and re-run the installer.`,
|
|
1205
|
-
);
|
|
1206
|
-
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1207
|
-
}
|
|
1208
|
-
managedCommand = shimIR.hookCommand;
|
|
1209
|
-
} else {
|
|
1210
|
-
managedCommand = projectManagedHookCommand({
|
|
1211
|
-
absoluteRunner,
|
|
1212
|
-
scriptPath,
|
|
1213
|
-
runtime: 'codex',
|
|
1214
|
-
platform,
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1219
|
-
return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
|
|
814
|
+
return hooksSurface.ensureCodexHooksJsonEvent(targetDir, eventName, opts);
|
|
1220
815
|
}
|
|
1221
816
|
|
|
1222
817
|
/**
|
|
@@ -1226,11 +821,11 @@ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
|
1226
821
|
* @param {string} eventName
|
|
1227
822
|
*/
|
|
1228
823
|
function removeCodexHooksJsonEvent(targetDir, eventName) {
|
|
1229
|
-
return
|
|
824
|
+
return hooksSurface.removeCodexHooksJsonEvent(targetDir, eventName);
|
|
1230
825
|
}
|
|
1231
826
|
|
|
1232
827
|
function removeCodexHooksJsonSessionStart(targetDir) {
|
|
1233
|
-
return
|
|
828
|
+
return hooksSurface.removeCodexHooksJsonSessionStart(targetDir);
|
|
1234
829
|
}
|
|
1235
830
|
|
|
1236
831
|
/**
|
|
@@ -1247,62 +842,7 @@ function removeCodexHooksJsonSessionStart(targetDir) {
|
|
|
1247
842
|
* runtime: target runtime name for shell projection policy.
|
|
1248
843
|
*/
|
|
1249
844
|
function buildHookCommand(configDir, hookName, opts) {
|
|
1250
|
-
|
|
1251
|
-
const platform = opts.platform || process.platform;
|
|
1252
|
-
const runtime = opts.runtime || 'generic';
|
|
1253
|
-
const isShellHook = hookName.endsWith('.sh');
|
|
1254
|
-
|
|
1255
|
-
// #166: Claude Code executes these hook commands inside a bash context on
|
|
1256
|
-
// Windows, so wrapping `.sh` hooks with an explicit `bash.exe` path can
|
|
1257
|
-
// trigger `bash.exe: ... cannot execute binary file`. Emit only the quoted
|
|
1258
|
-
// script path for Claude on Windows.
|
|
1259
|
-
if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
|
|
1260
|
-
if (opts.portableHooks) {
|
|
1261
|
-
const portableBaseDir = projectPortableHookBaseDir({
|
|
1262
|
-
configDir,
|
|
1263
|
-
homeDir: os.homedir(),
|
|
1264
|
-
});
|
|
1265
|
-
return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
|
|
1266
|
-
}
|
|
1267
|
-
return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
// POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
|
|
1271
|
-
// but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
|
|
1272
|
-
// Windows Codex launches hooks from PowerShell/cmd environments where bare
|
|
1273
|
-
// `bash` may not be on PATH, so resolve Git Bash explicitly or return null so
|
|
1274
|
-
// callers skip registration instead of installing a known-broken hook (#3393).
|
|
1275
|
-
// .js hooks still need the absolute node path because GUI-launched runtimes
|
|
1276
|
-
// start with a minimal PATH that may not include nvm/Homebrew/Volta node
|
|
1277
|
-
// binaries (#2979).
|
|
1278
|
-
const nodeRunner = resolveNodeRunner();
|
|
1279
|
-
const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
|
|
1280
|
-
// Runner resolvers return null when the executable path is unavailable.
|
|
1281
|
-
// Fall through with null so callers can skip registration with a warning
|
|
1282
|
-
// instead of emitting a command that recreates the original hook failure.
|
|
1283
|
-
if (runner === null) return null;
|
|
1284
|
-
|
|
1285
|
-
if (opts.portableHooks) {
|
|
1286
|
-
const portableBaseDir = projectPortableHookBaseDir({
|
|
1287
|
-
configDir,
|
|
1288
|
-
homeDir: os.homedir(),
|
|
1289
|
-
});
|
|
1290
|
-
return projectManagedHookCommand({
|
|
1291
|
-
absoluteRunner: runner,
|
|
1292
|
-
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
|
|
1293
|
-
runtime: opts.runtime || 'generic',
|
|
1294
|
-
platform,
|
|
1295
|
-
});
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
// Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046).
|
|
1299
|
-
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
|
|
1300
|
-
return projectManagedHookCommand({
|
|
1301
|
-
absoluteRunner: runner,
|
|
1302
|
-
scriptPath: hooksPath,
|
|
1303
|
-
runtime,
|
|
1304
|
-
platform,
|
|
1305
|
-
});
|
|
845
|
+
return hooksSurface.buildHookCommand(configDir, hookName, opts);
|
|
1306
846
|
}
|
|
1307
847
|
|
|
1308
848
|
/**
|
|
@@ -1669,6 +1209,52 @@ function injectEffortFrontmatter(content, effortValue) {
|
|
|
1669
1209
|
return `${before}effort: ${effortValue}${eol}${after}`;
|
|
1670
1210
|
}
|
|
1671
1211
|
|
|
1212
|
+
/**
|
|
1213
|
+
* #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
|
|
1214
|
+
* Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
|
|
1215
|
+
* inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
|
|
1216
|
+
* which break on unknown frontmatter keys.
|
|
1217
|
+
*/
|
|
1218
|
+
function injectDisallowedToolsFrontmatter(content, disallowedValue) {
|
|
1219
|
+
// Detect the dominant EOL from the first line (the opening `---`).
|
|
1220
|
+
// If the very first `---` is followed by \r\n, treat the whole file as CRLF.
|
|
1221
|
+
const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
|
|
1222
|
+
|
|
1223
|
+
// Build a frontmatter-matching regex that tolerates an optional \r before
|
|
1224
|
+
// each \n, so we handle both LF and CRLF files without needing to normalise
|
|
1225
|
+
// the whole content.
|
|
1226
|
+
const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
|
|
1227
|
+
const match = fmRe.exec(content);
|
|
1228
|
+
if (!match) return content; // no YAML frontmatter — leave unchanged
|
|
1229
|
+
|
|
1230
|
+
// Idempotency guard: don't insert a second disallowedTools: line.
|
|
1231
|
+
const fmBody = match[1]; // content between the two `---` lines
|
|
1232
|
+
if (/^disallowedTools:/m.test(fmBody)) return content;
|
|
1233
|
+
|
|
1234
|
+
// Locate the exact position of the closing `---` line so we can insert
|
|
1235
|
+
// before it using a simple string splice.
|
|
1236
|
+
const openLen = 3 + eol.length; // "---" + eol
|
|
1237
|
+
const closingStart = match.index + openLen + fmBody.length;
|
|
1238
|
+
|
|
1239
|
+
const before = content.slice(0, closingStart);
|
|
1240
|
+
const after = content.slice(closingStart);
|
|
1241
|
+
return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
|
|
1245
|
+
// Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
|
|
1246
|
+
// output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
|
|
1247
|
+
// excluded (it legitimately uses Write AND Edit to create/patch test files).
|
|
1248
|
+
const READONLY_AGENT_DISALLOWED_TOOLS = {
|
|
1249
|
+
'gsd-plan-checker': 'Write, Edit, MultiEdit',
|
|
1250
|
+
'gsd-integration-checker': 'Write, Edit, MultiEdit',
|
|
1251
|
+
'gsd-ui-checker': 'Write, Edit, MultiEdit',
|
|
1252
|
+
'gsd-verifier': 'Edit, MultiEdit',
|
|
1253
|
+
'gsd-doc-verifier': 'Edit, MultiEdit',
|
|
1254
|
+
'gsd-eval-auditor': 'Edit, MultiEdit',
|
|
1255
|
+
'gsd-ui-auditor': 'Edit, MultiEdit',
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1672
1258
|
/**
|
|
1673
1259
|
* #2517 — Read a single GSD config file (defaults.json or per-project
|
|
1674
1260
|
* config.json) into a plain object, returning null on missing/empty files
|
|
@@ -1896,6 +1482,35 @@ const claudeToGeminiTools = {
|
|
|
1896
1482
|
TodoWrite: 'write_todos',
|
|
1897
1483
|
};
|
|
1898
1484
|
|
|
1485
|
+
// Tool name mapping from Claude/GSD agents to Kimi CLI module paths.
|
|
1486
|
+
// Kimi custom agent YAML requires fully-qualified module paths.
|
|
1487
|
+
const claudeToKimiTools = {
|
|
1488
|
+
Read: 'kimi_cli.tools.file:ReadFile',
|
|
1489
|
+
ReadFile: 'kimi_cli.tools.file:ReadFile',
|
|
1490
|
+
Write: 'kimi_cli.tools.file:WriteFile',
|
|
1491
|
+
WriteFile: 'kimi_cli.tools.file:WriteFile',
|
|
1492
|
+
Edit: 'kimi_cli.tools.file:StrReplaceFile',
|
|
1493
|
+
MultiEdit: 'kimi_cli.tools.file:StrReplaceFile',
|
|
1494
|
+
StrReplaceFile: 'kimi_cli.tools.file:StrReplaceFile',
|
|
1495
|
+
Bash: 'kimi_cli.tools.shell:Shell',
|
|
1496
|
+
Shell: 'kimi_cli.tools.shell:Shell',
|
|
1497
|
+
Grep: 'kimi_cli.tools.file:Grep',
|
|
1498
|
+
Glob: 'kimi_cli.tools.file:Glob',
|
|
1499
|
+
Agent: 'kimi_cli.tools.agent:Agent',
|
|
1500
|
+
Task: 'kimi_cli.tools.agent:Agent',
|
|
1501
|
+
AskUserQuestion: 'kimi_cli.tools.ask_user:AskUserQuestion',
|
|
1502
|
+
TodoWrite: 'kimi_cli.tools.todo:SetTodoList',
|
|
1503
|
+
SetTodoList: 'kimi_cli.tools.todo:SetTodoList',
|
|
1504
|
+
WebSearch: 'kimi_cli.tools.web:SearchWeb',
|
|
1505
|
+
SearchWeb: 'kimi_cli.tools.web:SearchWeb',
|
|
1506
|
+
WebFetch: 'kimi_cli.tools.web:FetchURL',
|
|
1507
|
+
FetchURL: 'kimi_cli.tools.web:FetchURL',
|
|
1508
|
+
ReadMediaFile: 'kimi_cli.tools.file:ReadMediaFile',
|
|
1509
|
+
TaskList: 'kimi_cli.tools.background:TaskList',
|
|
1510
|
+
TaskOutput: 'kimi_cli.tools.background:TaskOutput',
|
|
1511
|
+
TaskStop: 'kimi_cli.tools.background:TaskStop',
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1899
1514
|
/**
|
|
1900
1515
|
* Convert a Claude Code tool name to OpenCode format
|
|
1901
1516
|
* - Applies special mappings (AskUserQuestion -> question, etc.)
|
|
@@ -1945,6 +1560,63 @@ function convertGeminiToolName(claudeTool) {
|
|
|
1945
1560
|
return claudeTool.toLowerCase();
|
|
1946
1561
|
}
|
|
1947
1562
|
|
|
1563
|
+
function createKimiToolDiagnostic(reason, tool, source = null) {
|
|
1564
|
+
const isMcp = reason === 'mcp_managed';
|
|
1565
|
+
return {
|
|
1566
|
+
level: 'warning',
|
|
1567
|
+
code: isMcp ? 'kimi_mcp_tool_excluded' : 'kimi_unsupported_tool',
|
|
1568
|
+
reason,
|
|
1569
|
+
message: isMcp
|
|
1570
|
+
? `MCP-managed tool '${tool}' is configured outside Kimi agent YAML.`
|
|
1571
|
+
: `Tool '${tool}' is not supported by the Kimi tool mapper.`,
|
|
1572
|
+
value: tool,
|
|
1573
|
+
source,
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Convert a Claude/GSD tool name to a Kimi CLI module path.
|
|
1579
|
+
* @returns {string|null} Kimi module path, or null when excluded/unsupported.
|
|
1580
|
+
*/
|
|
1581
|
+
function convertKimiToolName(claudeTool) {
|
|
1582
|
+
const tool = String(claudeTool || '').trim();
|
|
1583
|
+
if (!tool) return null;
|
|
1584
|
+
if (tool.startsWith('mcp__')) return null;
|
|
1585
|
+
return claudeToKimiTools[tool] || null;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function mapClaudeToolsToKimiTools(claudeTools, options = {}) {
|
|
1589
|
+
const diagnostics = [];
|
|
1590
|
+
const tools = [];
|
|
1591
|
+
const seen = new Set();
|
|
1592
|
+
const source = options && Object.prototype.hasOwnProperty.call(options, 'source')
|
|
1593
|
+
? options.source
|
|
1594
|
+
: null;
|
|
1595
|
+
|
|
1596
|
+
for (const rawTool of Array.isArray(claudeTools) ? claudeTools : []) {
|
|
1597
|
+
const tool = String(rawTool || '').trim();
|
|
1598
|
+
if (!tool) continue;
|
|
1599
|
+
|
|
1600
|
+
if (tool.startsWith('mcp__')) {
|
|
1601
|
+
diagnostics.push(createKimiToolDiagnostic('mcp_managed', tool, source));
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
const kimiTool = convertKimiToolName(tool);
|
|
1606
|
+
if (!kimiTool) {
|
|
1607
|
+
diagnostics.push(createKimiToolDiagnostic('unsupported_tool', tool, source));
|
|
1608
|
+
continue;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
if (!seen.has(kimiTool)) {
|
|
1612
|
+
seen.add(kimiTool);
|
|
1613
|
+
tools.push(kimiTool);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return { tools, diagnostics };
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1948
1620
|
const claudeToKiloAgentPermissions = {
|
|
1949
1621
|
Read: 'read',
|
|
1950
1622
|
Write: 'edit',
|
|
@@ -2065,12 +1737,13 @@ function convertClaudeToCopilotContent(content, isGlobal = false) {
|
|
|
2065
1737
|
return c;
|
|
2066
1738
|
}
|
|
2067
1739
|
|
|
1740
|
+
// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
|
|
2068
1741
|
/**
|
|
2069
1742
|
* Convert a Claude command (.md) to a Copilot skill (SKILL.md).
|
|
2070
1743
|
* Transforms frontmatter only — body passes through with CONV-06/07 applied.
|
|
2071
1744
|
* Skills keep original tool names (no mapping) per CONTEXT.md decision.
|
|
2072
1745
|
*/
|
|
2073
|
-
function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false) {
|
|
1746
|
+
function convertClaudeCommandToCopilotSkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
|
|
2074
1747
|
const converted = convertClaudeToCopilotContent(content, isGlobal);
|
|
2075
1748
|
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
|
2076
1749
|
if (!frontmatter) return converted;
|
|
@@ -2225,6 +1898,283 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
|
|
|
2225
1898
|
return `${fm}\n${normalizedBody}`;
|
|
2226
1899
|
}
|
|
2227
1900
|
|
|
1901
|
+
function normalizeKimiSkillName(skillName) {
|
|
1902
|
+
let text = String(skillName || '').trim().toLowerCase();
|
|
1903
|
+
if (text.startsWith('/')) text = text.slice(1);
|
|
1904
|
+
if (text.startsWith('$')) text = text.slice(1);
|
|
1905
|
+
text = text.replace(/^gsd:/, 'gsd-');
|
|
1906
|
+
if (!text.startsWith('gsd-')) text = `gsd-${text}`;
|
|
1907
|
+
text = text.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
1908
|
+
return text || 'gsd-command';
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function convertGsdCommandReferencesToKimiSkillInvocations(content, cmdNames) {
|
|
1912
|
+
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return content;
|
|
1913
|
+
const commands = [...cmdNames].sort((a, b) => b.length - a.length).map(escapeRegExp);
|
|
1914
|
+
const commandGroup = commands.join('|');
|
|
1915
|
+
const colonPattern = new RegExp(`(?<![A-Za-z0-9_/:.-])/?gsd:(${commandGroup})(?=[^A-Za-z0-9_-]|$)`, 'g');
|
|
1916
|
+
const hyphenPattern = new RegExp(`(?:/|\\$)gsd-(${commandGroup})(?=[^A-Za-z0-9_-]|$)`, 'g');
|
|
1917
|
+
|
|
1918
|
+
return content
|
|
1919
|
+
.replace(colonPattern, (_, cmd) => `/skill:gsd-${cmd}`)
|
|
1920
|
+
.replace(hyphenPattern, (_, cmd) => `/skill:gsd-${cmd}`);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function convertClaudeCommandToKimiSkill(content, skillName, _runtime = null, cmdNames = null) {
|
|
1924
|
+
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
|
1925
|
+
const kimiSkillName = normalizeKimiSkillName(skillName);
|
|
1926
|
+
const names = cmdNames || readGsdCommandNames();
|
|
1927
|
+
const description = frontmatter
|
|
1928
|
+
? extractFrontmatterField(frontmatter, 'description') || `Run GSD workflow ${kimiSkillName}.`
|
|
1929
|
+
: `Run GSD workflow ${kimiSkillName}.`;
|
|
1930
|
+
const normalizedBody = convertGsdCommandReferencesToKimiSkillInvocations(
|
|
1931
|
+
frontmatter ? body : content,
|
|
1932
|
+
names
|
|
1933
|
+
);
|
|
1934
|
+
|
|
1935
|
+
return `---\nname: ${kimiSkillName}\ndescription: ${yamlQuote(toSingleLine(description))}\n---\nInvoke this Kimi skill with \`/skill:${kimiSkillName}\`.\n\n${normalizedBody}`;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const KIMI_CANONICAL_GSD_AGENT_RE = /^gsd-[a-z0-9-]+$/;
|
|
1939
|
+
|
|
1940
|
+
function parseKimiAgentSource(source) {
|
|
1941
|
+
if (typeof source === 'string') {
|
|
1942
|
+
return {
|
|
1943
|
+
path: null,
|
|
1944
|
+
content: source,
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
if (!source || typeof source !== 'object' || typeof source.content !== 'string') {
|
|
1948
|
+
return null;
|
|
1949
|
+
}
|
|
1950
|
+
return {
|
|
1951
|
+
path: typeof source.path === 'string' ? source.path : null,
|
|
1952
|
+
content: source.content,
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
function parseFrontmatterTools(frontmatter) {
|
|
1957
|
+
if (!frontmatter) return [];
|
|
1958
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
1959
|
+
const tools = [];
|
|
1960
|
+
let collecting = false;
|
|
1961
|
+
|
|
1962
|
+
for (const line of lines) {
|
|
1963
|
+
const trimmed = line.trim();
|
|
1964
|
+
if (!trimmed) continue;
|
|
1965
|
+
|
|
1966
|
+
if (collecting) {
|
|
1967
|
+
if (trimmed.startsWith('- ')) {
|
|
1968
|
+
tools.push(trimmed.slice(2).trim());
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
collecting = false;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
if (trimmed === 'tools:' || trimmed === 'allowed-tools:') {
|
|
1975
|
+
collecting = true;
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
if (trimmed.startsWith('tools:') || trimmed.startsWith('allowed-tools:')) {
|
|
1980
|
+
const value = trimmed.slice(trimmed.indexOf(':') + 1).trim();
|
|
1981
|
+
if (value) {
|
|
1982
|
+
for (const tool of value.split(',')) {
|
|
1983
|
+
const name = tool.trim();
|
|
1984
|
+
if (name) tools.push(name);
|
|
1985
|
+
}
|
|
1986
|
+
} else {
|
|
1987
|
+
collecting = true;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
return tools;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function addKimiAgentDiagnostic(diagnostics, code, message, value, source = null) {
|
|
1996
|
+
diagnostics.push({
|
|
1997
|
+
level: 'warning',
|
|
1998
|
+
code,
|
|
1999
|
+
message,
|
|
2000
|
+
value,
|
|
2001
|
+
source,
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
function mapKimiAgentContractTools(toolNames, diagnostics, sourceName) {
|
|
2006
|
+
const result = mapClaudeToolsToKimiTools(toolNames, { source: sourceName });
|
|
2007
|
+
diagnostics.push(...result.diagnostics);
|
|
2008
|
+
return result.tools;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function neutralizeKimiAgentPrompt(content) {
|
|
2012
|
+
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
|
2013
|
+
let prompt = frontmatter ? body : content;
|
|
2014
|
+
prompt = neutralizeAgentReferences(prompt, 'AGENTS.md');
|
|
2015
|
+
prompt = prompt.replace(/~\/\.claude\/gsd-core\b/g, 'GSD core');
|
|
2016
|
+
prompt = prompt.replace(/\$HOME\/\.claude\/gsd-core\b/g, 'GSD core');
|
|
2017
|
+
return prompt.replace(/^\s*\r?\n/, '');
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function pushKimiToolsYaml(lines, indent, tools) {
|
|
2021
|
+
const prefix = ' '.repeat(indent);
|
|
2022
|
+
if (!Array.isArray(tools) || tools.length === 0) {
|
|
2023
|
+
lines.push(`${prefix}tools: []`);
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
lines.push(`${prefix}tools:`);
|
|
2027
|
+
for (const tool of tools) {
|
|
2028
|
+
lines.push(`${prefix} - ${yamlQuote(tool)}`);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function buildKimiRootAgentYaml({ description, tools, subagents }) {
|
|
2033
|
+
const lines = [
|
|
2034
|
+
'version: 1',
|
|
2035
|
+
'agent:',
|
|
2036
|
+
' name: gsd',
|
|
2037
|
+
` description: ${yamlQuote(toSingleLine(description || 'Run GSD workflows in Kimi CLI.'))}`,
|
|
2038
|
+
' extend: default',
|
|
2039
|
+
' system_prompt_path: ./gsd.md',
|
|
2040
|
+
];
|
|
2041
|
+
pushKimiToolsYaml(lines, 2, tools);
|
|
2042
|
+
|
|
2043
|
+
if (subagents.length > 0) {
|
|
2044
|
+
lines.push(' subagents:');
|
|
2045
|
+
for (const subagent of subagents) {
|
|
2046
|
+
lines.push(` ${subagent.name}:`);
|
|
2047
|
+
lines.push(` path: ./subagents/${subagent.name}.yaml`);
|
|
2048
|
+
lines.push(` description: ${yamlQuote(toSingleLine(subagent.description))}`);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
return `${lines.join('\n')}\n`;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function buildKimiSubagentYaml({ name, description, tools }) {
|
|
2056
|
+
const lines = [
|
|
2057
|
+
'version: 1',
|
|
2058
|
+
'agent:',
|
|
2059
|
+
` name: ${name}`,
|
|
2060
|
+
` description: ${yamlQuote(toSingleLine(description || `Run ${name}.`))}`,
|
|
2061
|
+
` system_prompt_path: ./${name}.md`,
|
|
2062
|
+
];
|
|
2063
|
+
pushKimiToolsYaml(lines, 2, tools);
|
|
2064
|
+
return `${lines.join('\n')}\n`;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function buildKimiAgentArtifacts({
|
|
2068
|
+
rootAgent = '',
|
|
2069
|
+
subagents = [],
|
|
2070
|
+
requestedSubagents = null,
|
|
2071
|
+
} = {}) {
|
|
2072
|
+
const diagnostics = [];
|
|
2073
|
+
const rootSource = parseKimiAgentSource(rootAgent) || { path: null, content: '' };
|
|
2074
|
+
const { frontmatter: rootFrontmatter } = extractFrontmatterAndBody(rootSource.content);
|
|
2075
|
+
const rootDescription = rootFrontmatter
|
|
2076
|
+
? extractFrontmatterField(rootFrontmatter, 'description') || 'Run GSD workflows in Kimi CLI.'
|
|
2077
|
+
: 'Run GSD workflows in Kimi CLI.';
|
|
2078
|
+
|
|
2079
|
+
const subagentSources = Array.isArray(subagents) ? subagents : [];
|
|
2080
|
+
if (!Array.isArray(subagents)) {
|
|
2081
|
+
addKimiAgentDiagnostic(
|
|
2082
|
+
diagnostics,
|
|
2083
|
+
'kimi_unsupported_subagents_input',
|
|
2084
|
+
'Subagents input must be an array of Markdown strings or source objects.',
|
|
2085
|
+
typeof subagents,
|
|
2086
|
+
null
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
const subagentMap = new Map();
|
|
2091
|
+
for (const source of subagentSources) {
|
|
2092
|
+
const parsed = parseKimiAgentSource(source);
|
|
2093
|
+
if (!parsed) {
|
|
2094
|
+
addKimiAgentDiagnostic(
|
|
2095
|
+
diagnostics,
|
|
2096
|
+
'kimi_unsupported_subagent_input',
|
|
2097
|
+
'Subagent source must be a Markdown string or an object with content.',
|
|
2098
|
+
typeof source,
|
|
2099
|
+
null
|
|
2100
|
+
);
|
|
2101
|
+
continue;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
const { frontmatter } = extractFrontmatterAndBody(parsed.content);
|
|
2105
|
+
const fallbackName = parsed.path ? path.basename(parsed.path, path.extname(parsed.path)) : null;
|
|
2106
|
+
const name = frontmatter
|
|
2107
|
+
? extractFrontmatterField(frontmatter, 'name') || fallbackName
|
|
2108
|
+
: fallbackName;
|
|
2109
|
+
if (!name || !KIMI_CANONICAL_GSD_AGENT_RE.test(name)) {
|
|
2110
|
+
addKimiAgentDiagnostic(
|
|
2111
|
+
diagnostics,
|
|
2112
|
+
'kimi_invalid_subagent_name',
|
|
2113
|
+
'Subagent source does not use a canonical gsd-* Kimi agent name.',
|
|
2114
|
+
name || '(missing)',
|
|
2115
|
+
parsed.path
|
|
2116
|
+
);
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
const description = frontmatter
|
|
2121
|
+
? extractFrontmatterField(frontmatter, 'description') || `Run ${name}.`
|
|
2122
|
+
: `Run ${name}.`;
|
|
2123
|
+
const tools = mapKimiAgentContractTools(parseFrontmatterTools(frontmatter), diagnostics, name);
|
|
2124
|
+
subagentMap.set(name, {
|
|
2125
|
+
name,
|
|
2126
|
+
description,
|
|
2127
|
+
tools,
|
|
2128
|
+
prompt: neutralizeKimiAgentPrompt(parsed.content),
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
const requested = Array.isArray(requestedSubagents) && requestedSubagents.length > 0
|
|
2133
|
+
? requestedSubagents
|
|
2134
|
+
: [...subagentMap.keys()];
|
|
2135
|
+
const selectedSubagents = [];
|
|
2136
|
+
for (const requestedName of requested) {
|
|
2137
|
+
if (subagentMap.has(requestedName)) {
|
|
2138
|
+
selectedSubagents.push(subagentMap.get(requestedName));
|
|
2139
|
+
continue;
|
|
2140
|
+
}
|
|
2141
|
+
addKimiAgentDiagnostic(
|
|
2142
|
+
diagnostics,
|
|
2143
|
+
'kimi_unknown_subagent',
|
|
2144
|
+
'Requested subagent was not generated and will not be emitted in Kimi YAML.',
|
|
2145
|
+
requestedName,
|
|
2146
|
+
null
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
const rootTools = mapKimiAgentContractTools(parseFrontmatterTools(rootFrontmatter), diagnostics, 'gsd');
|
|
2151
|
+
if (selectedSubagents.length > 0 && !rootTools.includes('kimi_cli.tools.agent:Agent')) {
|
|
2152
|
+
rootTools.push('kimi_cli.tools.agent:Agent');
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
return {
|
|
2156
|
+
root: {
|
|
2157
|
+
name: 'gsd',
|
|
2158
|
+
yamlPath: 'agents/gsd.yaml',
|
|
2159
|
+
promptPath: 'agents/gsd.md',
|
|
2160
|
+
yaml: buildKimiRootAgentYaml({
|
|
2161
|
+
description: rootDescription,
|
|
2162
|
+
tools: rootTools,
|
|
2163
|
+
subagents: selectedSubagents,
|
|
2164
|
+
}),
|
|
2165
|
+
prompt: neutralizeKimiAgentPrompt(rootSource.content),
|
|
2166
|
+
},
|
|
2167
|
+
subagents: selectedSubagents.map((subagent) => ({
|
|
2168
|
+
name: subagent.name,
|
|
2169
|
+
yamlPath: `agents/subagents/${subagent.name}.yaml`,
|
|
2170
|
+
promptPath: `agents/subagents/${subagent.name}.md`,
|
|
2171
|
+
yaml: buildKimiSubagentYaml(subagent),
|
|
2172
|
+
prompt: subagent.prompt,
|
|
2173
|
+
})),
|
|
2174
|
+
diagnostics,
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2228
2178
|
/**
|
|
2229
2179
|
* Convert a Claude agent (.md) to a Copilot agent (.agent.md).
|
|
2230
2180
|
* Applies tool mapping + deduplication, formats tools as JSON array.
|
|
@@ -2261,8 +2211,8 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
|
|
|
2261
2211
|
/**
|
|
2262
2212
|
* Apply Antigravity-specific content conversion — path replacement + command name conversion.
|
|
2263
2213
|
* Path mappings depend on install mode:
|
|
2264
|
-
* Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.
|
|
2265
|
-
* Local: ~/.claude/ → .
|
|
2214
|
+
* Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
|
|
2215
|
+
* Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
|
|
2266
2216
|
* Applied to ALL Antigravity content (skills, agents, engine files).
|
|
2267
2217
|
* @param {string} content - Source content to convert
|
|
2268
2218
|
* @param {boolean} [isGlobal=false] - Whether this is a global install
|
|
@@ -2276,14 +2226,14 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
|
|
|
2276
2226
|
c = c.replace(/\$HOME\/\.claude\b/g, '$HOME/.gemini/antigravity');
|
|
2277
2227
|
c = c.replace(/~\/\.claude\b/g, '~/.gemini/antigravity');
|
|
2278
2228
|
} else {
|
|
2279
|
-
c = c.replace(/\$HOME\/\.claude\//g, '.
|
|
2280
|
-
c = c.replace(/~\/\.claude\//g, '.
|
|
2229
|
+
c = c.replace(/\$HOME\/\.claude\//g, '.agents/');
|
|
2230
|
+
c = c.replace(/~\/\.claude\//g, '.agents/');
|
|
2281
2231
|
// Bare form (no trailing slash) — must come after slash form to avoid double-replace
|
|
2282
|
-
c = c.replace(/\$HOME\/\.claude\b/g, '.
|
|
2283
|
-
c = c.replace(/~\/\.claude\b/g, '.
|
|
2232
|
+
c = c.replace(/\$HOME\/\.claude\b/g, '.agents');
|
|
2233
|
+
c = c.replace(/~\/\.claude\b/g, '.agents');
|
|
2284
2234
|
}
|
|
2285
|
-
c = c.replace(/\.\/\.claude\//g, './.
|
|
2286
|
-
c = c.replace(/\.claude\//g, '.
|
|
2235
|
+
c = c.replace(/\.\/\.claude\//g, './.agents/');
|
|
2236
|
+
c = c.replace(/\.claude\//g, '.agents/');
|
|
2287
2237
|
// Command name conversion (all gsd: references → gsd-)
|
|
2288
2238
|
c = c.replace(/gsd:/g, 'gsd-');
|
|
2289
2239
|
// Runtime-neutral agent name replacement (#766)
|
|
@@ -2291,12 +2241,13 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
|
|
|
2291
2241
|
return c;
|
|
2292
2242
|
}
|
|
2293
2243
|
|
|
2244
|
+
// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
|
|
2294
2245
|
/**
|
|
2295
2246
|
* Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
|
|
2296
2247
|
* Transforms frontmatter to minimal name + description only.
|
|
2297
2248
|
* Body passes through with path/command conversions applied.
|
|
2298
2249
|
*/
|
|
2299
|
-
function convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal = false) {
|
|
2250
|
+
function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
|
|
2300
2251
|
const converted = convertClaudeToAntigravityContent(content, isGlobal);
|
|
2301
2252
|
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
|
2302
2253
|
if (!frontmatter) return converted;
|
|
@@ -2555,12 +2506,22 @@ function convertClaudeToWindsurfMarkdown(content) {
|
|
|
2555
2506
|
// Replace subagent_type from Claude to Windsurf format
|
|
2556
2507
|
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
|
|
2557
2508
|
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
|
|
2558
|
-
// Replace project-level Claude conventions with Windsurf equivalents
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
converted = converted.replace(
|
|
2562
|
-
converted = converted.replace(
|
|
2563
|
-
converted = converted.replace(
|
|
2509
|
+
// Replace project-level Claude conventions with Windsurf/Devin equivalents
|
|
2510
|
+
// Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
|
|
2511
|
+
// Legacy .windsurf/ is still recognized on read but new installs use .devin/.
|
|
2512
|
+
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`');
|
|
2513
|
+
converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules');
|
|
2514
|
+
converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`');
|
|
2515
|
+
converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules');
|
|
2516
|
+
converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/');
|
|
2517
|
+
converted = converted.replace(/\.\/\.claude\//g, './.devin/');
|
|
2518
|
+
converted = converted.replace(/\.claude\//g, '.devin/');
|
|
2519
|
+
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
|
|
2520
|
+
// Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
|
|
2521
|
+
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin');
|
|
2522
|
+
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin');
|
|
2523
|
+
// Environment variable name rewrite
|
|
2524
|
+
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
|
|
2564
2525
|
// Remove Claude Code-specific bug workarounds before brand replacement
|
|
2565
2526
|
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
|
|
2566
2527
|
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
|
|
@@ -2782,6 +2743,12 @@ function convertClaudeToTraeMarkdown(content) {
|
|
|
2782
2743
|
converted = converted.replace(/\.claude\/skills\//g, '.trae/skills/');
|
|
2783
2744
|
converted = converted.replace(/\.\/\.claude\//g, './.trae/');
|
|
2784
2745
|
converted = converted.replace(/\.claude\//g, '.trae/');
|
|
2746
|
+
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
|
|
2747
|
+
// Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
|
|
2748
|
+
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.trae');
|
|
2749
|
+
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.trae');
|
|
2750
|
+
// Environment variable name rewrite
|
|
2751
|
+
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'TRAE_CONFIG_DIR');
|
|
2785
2752
|
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
|
|
2786
2753
|
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
|
|
2787
2754
|
converted = converted.replace(/\bClaude Code\b/g, 'Trae');
|
|
@@ -3009,6 +2976,16 @@ function convertSlashCommandsToCodexSkillMentions(content) {
|
|
|
3009
2976
|
return converted;
|
|
3010
2977
|
}
|
|
3011
2978
|
|
|
2979
|
+
const CODEX_GSD_TOOLS_INVOCATION = 'node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs"';
|
|
2980
|
+
|
|
2981
|
+
function rewriteBareGsdToolsCommandsForCodex(content) {
|
|
2982
|
+
return content
|
|
2983
|
+
.replace(/(^[ \t]*)gsd-tools(?=\s)/gm, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
|
|
2984
|
+
.replace(/(\$\(\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
|
|
2985
|
+
.replace(/(`\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
|
|
2986
|
+
.replace(/((?:&&|\|\||[;|])\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`);
|
|
2987
|
+
}
|
|
2988
|
+
|
|
3012
2989
|
function convertClaudeToCodexMarkdown(content) {
|
|
3013
2990
|
let converted = convertSlashCommandsToCodexSkillMentions(content);
|
|
3014
2991
|
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
|
|
@@ -3034,6 +3011,10 @@ function convertClaudeToCodexMarkdown(content) {
|
|
|
3034
3011
|
// `.claudeignore` → `.codexignore` (#2639). Codex honors its own ignore
|
|
3035
3012
|
// file; leaving the Claude-specific name is misleading in agent prompts.
|
|
3036
3013
|
converted = converted.replace(/\.claudeignore\b/g, '.codexignore');
|
|
3014
|
+
// Codex installs the tools shim under ~/.codex but does not guarantee a
|
|
3015
|
+
// bare `gsd-tools` binary on PATH. Keep resolver probes such as
|
|
3016
|
+
// `command -v gsd-tools` intact; rewrite only command invocations.
|
|
3017
|
+
converted = rewriteBareGsdToolsCommandsForCodex(converted);
|
|
3037
3018
|
// Runtime-neutral agent name replacement (#766)
|
|
3038
3019
|
converted = neutralizeAgentReferences(converted, 'AGENTS.md');
|
|
3039
3020
|
return converted;
|
|
@@ -3073,7 +3054,13 @@ Execute mode fallback:
|
|
|
3073
3054
|
## C. Task() → spawn_agent Mapping
|
|
3074
3055
|
GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools:
|
|
3075
3056
|
|
|
3076
|
-
|
|
3057
|
+
**Schema detection (required first step):** Codex exposes two \`spawn_agent\` schemas:
|
|
3058
|
+
- **agent_type-capable schema** (e.g. \`multi_agent_v2\`): \`spawn_agent\` accepts \`agent_type\`, \`message\`, \`reasoning_effort\`, \`fork_context\`, etc. — typed GSD agent dispatch is available.
|
|
3059
|
+
- **Generic schema** (\`multi_agent_v1\`): \`spawn_agent\` accepts only \`message\`, \`items\`, \`fork_context\` — there is **no \`agent_type\` field**. Typed GSD agent dispatch is unavailable in this session.
|
|
3060
|
+
|
|
3061
|
+
Before spawning, inspect the \`spawn_agent\` tool's visible parameter schema (via \`tool_search\` or the tool list) to determine which form is active.
|
|
3062
|
+
|
|
3063
|
+
Typed mapping (agent_type-capable schema only):
|
|
3077
3064
|
- \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
|
|
3078
3065
|
- \`Agent(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
|
|
3079
3066
|
- \`Task(model="...")\` → omit. \`spawn_agent\` has no inline \`model\` parameter;
|
|
@@ -3090,6 +3077,24 @@ Direct mapping:
|
|
|
3090
3077
|
Workflows that require this isolation must fail closed or use an explicit
|
|
3091
3078
|
manual worktree protocol before spawning (#3360).
|
|
3092
3079
|
|
|
3080
|
+
Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
|
|
3081
|
+
When only the generic \`multi_agent_v1\` schema is available, typed GSD agent dispatch
|
|
3082
|
+
(\`gsd-planner\`, \`gsd-executor\`, etc.) is NOT possible. This is a known Codex limitation
|
|
3083
|
+
(openai/codex#15250). **This workaround is NOT equivalent to typed gsd-planner/gsd-executor
|
|
3084
|
+
execution** — GSD agents carry project-aware prompts, audit logging, and workflow context
|
|
3085
|
+
that a generic subagent lacks. Use the following fallback:
|
|
3086
|
+
1. Resolve your active Codex config root — the directory that contains your \`config.toml\`.
|
|
3087
|
+
This directory is determined in priority order: \`$CODEX_HOME\` (if set), the path given
|
|
3088
|
+
by \`--config-dir\` (if passed on invocation), a local \`.codex\` directory in the current
|
|
3089
|
+
project (if \`--local\` was used), or the default global config directory. Read
|
|
3090
|
+
\`agents/<agent-name>.toml\` relative to that config root to extract the agent's system
|
|
3091
|
+
instructions.
|
|
3092
|
+
2. Inject those instructions as a role-preamble into a generic \`spawn_agent(message=...)\` call.
|
|
3093
|
+
3. Label results and logs clearly as "generic-agent workaround" so the orchestrator and user
|
|
3094
|
+
know full typed-agent guarantees are not in effect.
|
|
3095
|
+
4. Where typed dispatch is mandatory for correctness (e.g. worktree isolation), fail closed
|
|
3096
|
+
and report the schema limitation rather than silently degrading.
|
|
3097
|
+
|
|
3093
3098
|
Spawn restriction:
|
|
3094
3099
|
- Codex restricts \`spawn_agent\` to cases where the user has explicitly
|
|
3095
3100
|
requested sub-agents. When automatic spawning is not permitted, do the
|
|
@@ -3182,14 +3187,17 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
|
|
|
3182
3187
|
// Task() model parameters). See #2256.
|
|
3183
3188
|
// Precedence: per-agent model_overrides > runtime-aware tier resolution (#2517).
|
|
3184
3189
|
const modelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
|
|
3190
|
+
let hasPinnedModel = false;
|
|
3185
3191
|
if (modelOverride) {
|
|
3186
3192
|
lines.push(`model = ${JSON.stringify(modelOverride)}`);
|
|
3193
|
+
hasPinnedModel = true;
|
|
3187
3194
|
} else if (runtimeResolver) {
|
|
3188
3195
|
// #2517 — runtime-aware tier resolution. Embeds Codex-native model + reasoning_effort
|
|
3189
3196
|
// from RUNTIME_PROFILE_MAP / model_profile_overrides for the configured tier.
|
|
3190
3197
|
const entry = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
|
|
3191
3198
|
if (entry?.model) {
|
|
3192
3199
|
lines.push(`model = ${JSON.stringify(entry.model)}`);
|
|
3200
|
+
hasPinnedModel = true;
|
|
3193
3201
|
// model is resolved here; reasoning_effort from catalog tier is REPLACED by the
|
|
3194
3202
|
// unified effort resolver below (#443). Do NOT emit entry.reasoning_effort here.
|
|
3195
3203
|
}
|
|
@@ -3200,9 +3208,15 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
|
|
|
3200
3208
|
// from the same effort.agent_overrides / effort.routing_tier_defaults / effort.default
|
|
3201
3209
|
// config source. Codex does not support 'max' → clamped to 'xhigh' by
|
|
3202
3210
|
// gsdRenderEffortForRuntime('codex', ...).
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3211
|
+
// #838 — Do not pin effort when Codex is intentionally inheriting the parent
|
|
3212
|
+
// chat model. A TOML with no `model` but a static `model_reasoning_effort`
|
|
3213
|
+
// creates confusing partial routing: model follows the Codex UI while effort
|
|
3214
|
+
// follows GSD. Keep those knobs coupled unless GSD also pins the model.
|
|
3215
|
+
if (hasPinnedModel) {
|
|
3216
|
+
const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
|
|
3217
|
+
const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
|
|
3218
|
+
lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
|
|
3219
|
+
}
|
|
3206
3220
|
|
|
3207
3221
|
// #774 — Emit service_tier and model_verbosity for light-tier agents.
|
|
3208
3222
|
// Light-tier agents (routingTier: "light" in model-catalog.json) are haiku-equivalent
|
|
@@ -5196,36 +5210,14 @@ function rewriteTomlKeyLines(content, matches, key) {
|
|
|
5196
5210
|
return rewritten;
|
|
5197
5211
|
}
|
|
5198
5212
|
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
* _cleanTmpFiles() can scope cleanup to files this installer process actually
|
|
5208
|
-
* created, avoiding accidental deletion of unrelated tools' temp files.
|
|
5209
|
-
*/
|
|
5210
|
-
let __atomicWriteCounter = 0;
|
|
5211
|
-
// Set<string> — absolute paths of .tmp-<pid>-<n> files this process created.
|
|
5212
|
-
const __atomicWrittenTmps = new Set();
|
|
5213
|
-
function atomicWriteFileSync(target, data, options) {
|
|
5214
|
-
__atomicWriteCounter += 1;
|
|
5215
|
-
const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
|
|
5216
|
-
__atomicWrittenTmps.add(tmp);
|
|
5217
|
-
try {
|
|
5218
|
-
fs.writeFileSync(tmp, data, options);
|
|
5219
|
-
fs.renameSync(tmp, target);
|
|
5220
|
-
// Successful rename: the tmp path no longer exists, but leave it in the
|
|
5221
|
-
// Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
|
|
5222
|
-
// lingers (e.g. a rename succeeded but left a stale entry on some FS).
|
|
5223
|
-
} catch (e) {
|
|
5224
|
-
// Best-effort cleanup of the partial temp file; never mask the real error.
|
|
5225
|
-
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
|
|
5226
|
-
throw e;
|
|
5227
|
-
}
|
|
5228
|
-
}
|
|
5213
|
+
// atomicWriteFileSync and __atomicWrittenTmps are now owned by the
|
|
5214
|
+
// runtime-hooks-surface module and imported here so both install.js's
|
|
5215
|
+
// direct config.toml writes and the module's Cursor/Codex hooks.json
|
|
5216
|
+
// writes share the SAME tracking Set. _cleanTmpFiles() below reads
|
|
5217
|
+
// hooksSurface.__atomicWrittenTmps to scope cleanup to installer-owned
|
|
5218
|
+
// temps only.
|
|
5219
|
+
const atomicWriteFileSync = hooksSurface.atomicWriteFileSync;
|
|
5220
|
+
const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
|
|
5229
5221
|
|
|
5230
5222
|
/**
|
|
5231
5223
|
* Merge GSD config block into an existing or new config.toml.
|
|
@@ -5273,7 +5265,7 @@ function mergeCodexConfig(configPath, gsdBlock) {
|
|
|
5273
5265
|
|
|
5274
5266
|
/**
|
|
5275
5267
|
* Repair config.toml files corrupted by pre-#1346 GSD installs.
|
|
5276
|
-
* Non-boolean keys (e.g. model = "gpt-5.
|
|
5268
|
+
* Non-boolean keys (e.g. model = "gpt-5.4") that ended up under [features]
|
|
5277
5269
|
* are relocated before the [features] header so Codex can parse them correctly.
|
|
5278
5270
|
* Returns the content unchanged if no trapped keys are found.
|
|
5279
5271
|
*/
|
|
@@ -5563,23 +5555,12 @@ const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
|
|
|
5563
5555
|
* engine layout, not the (separate) #782 Cline skills directory.
|
|
5564
5556
|
*/
|
|
5565
5557
|
function buildClineRulesBody() {
|
|
5566
|
-
return
|
|
5567
|
-
'# GSD Core — Git. Ship. Done.',
|
|
5568
|
-
'',
|
|
5569
|
-
'- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
|
|
5570
|
-
' the user runs a `/gsd-*` command.',
|
|
5571
|
-
'- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
|
|
5572
|
-
'- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
|
|
5573
|
-
'- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
|
|
5574
|
-
'- Do not apply GSD workflows unless the user explicitly asks for them.',
|
|
5575
|
-
'- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
|
|
5576
|
-
' step to the user using Cline\'s ask_user tool after completing it.',
|
|
5577
|
-
].join('\n') + '\n';
|
|
5558
|
+
return hooksSurface.buildClineRulesBody();
|
|
5578
5559
|
}
|
|
5579
5560
|
|
|
5580
5561
|
/** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
|
|
5581
5562
|
function buildClineAgentsMdBody() {
|
|
5582
|
-
return
|
|
5563
|
+
return hooksSurface.buildClineAgentsMdBody();
|
|
5583
5564
|
}
|
|
5584
5565
|
|
|
5585
5566
|
/**
|
|
@@ -5588,92 +5569,22 @@ function buildClineAgentsMdBody() {
|
|
|
5588
5569
|
* Cline invokes hooks as executable scripts named exactly after the event with
|
|
5589
5570
|
* no extension, passing the operation context as JSON on stdin and reading a
|
|
5590
5571
|
* JSON decision from stdout ({ cancel, errorMessage, contextModification }).
|
|
5591
|
-
*
|
|
5592
|
-
* This hook is a self-standing planning-artifact guard: it cancels write-class
|
|
5593
|
-
* tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
|
|
5594
|
-
* allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
|
|
5595
|
-
* hook bug can never wedge the user. No dependency on the #782 skills work.
|
|
5596
|
-
*/
|
|
5597
|
-
function buildClinePreToolUseHook() {
|
|
5598
|
-
return
|
|
5599
|
-
|
|
5600
|
-
/* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
|
|
5601
|
-
* Protocol: JSON on stdin -> JSON decision on stdout.
|
|
5602
|
-
* Honored fields: { cancel, errorMessage, contextModification }.
|
|
5603
|
-
* Fails open: any error allows the operation. */
|
|
5604
|
-
let raw = '';
|
|
5605
|
-
process.stdin.setEncoding('utf8');
|
|
5606
|
-
process.stdin.on('data', (c) => { raw += c; });
|
|
5607
|
-
process.stdin.on('end', () => {
|
|
5608
|
-
const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
|
|
5609
|
-
let input;
|
|
5610
|
-
try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
|
|
5611
|
-
try {
|
|
5612
|
-
const tool = String(
|
|
5613
|
-
input.toolName || input.tool_name || input.tool ||
|
|
5614
|
-
(input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
|
|
5615
|
-
).toLowerCase();
|
|
5616
|
-
const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
|
|
5617
|
-
// Collect only PATH-bearing field values (not free-form content), so a doc
|
|
5618
|
-
// that merely mentions ".planning/" in its body is never falsely blocked.
|
|
5619
|
-
const paths = [];
|
|
5620
|
-
const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
|
5621
|
-
const walk = (v, depth) => {
|
|
5622
|
-
if (depth > 5 || paths.length > 64) return;
|
|
5623
|
-
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
|
|
5624
|
-
if (v && typeof v === 'object') {
|
|
5625
|
-
for (const k of Object.keys(v)) {
|
|
5626
|
-
const val = v[k];
|
|
5627
|
-
if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
|
|
5628
|
-
else walk(val, depth + 1);
|
|
5629
|
-
}
|
|
5630
|
-
}
|
|
5631
|
-
};
|
|
5632
|
-
walk(input, 0);
|
|
5633
|
-
const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
|
|
5634
|
-
if (isWrite && paths.some(isPlanningPath)) {
|
|
5635
|
-
return process.stdout.write(JSON.stringify({
|
|
5636
|
-
cancel: true,
|
|
5637
|
-
errorMessage:
|
|
5638
|
-
'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
|
|
5639
|
-
}));
|
|
5640
|
-
}
|
|
5641
|
-
} catch { /* fall through to allow */ }
|
|
5642
|
-
return allow();
|
|
5643
|
-
});
|
|
5644
|
-
`;
|
|
5645
|
-
}
|
|
5646
|
-
|
|
5647
|
-
/**
|
|
5648
|
-
* Merge the GSD AGENTS.md block into an existing file (or create it), preserving
|
|
5649
|
-
* any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
|
|
5650
|
-
*/
|
|
5651
|
-
function mergeGsdAgentsMd(filePath, gsdContent) {
|
|
5652
|
-
const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER;
|
|
5653
|
-
|
|
5654
|
-
if (!fs.existsSync(filePath)) {
|
|
5655
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
5656
|
-
fs.writeFileSync(filePath, gsdBlock + '\n');
|
|
5657
|
-
return;
|
|
5658
|
-
}
|
|
5659
|
-
|
|
5660
|
-
const existing = fs.readFileSync(filePath, 'utf8');
|
|
5661
|
-
const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
|
|
5662
|
-
const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
|
|
5663
|
-
|
|
5664
|
-
if (openIndex !== -1 && closeIndex !== -1) {
|
|
5665
|
-
const before = existing.substring(0, openIndex).trimEnd();
|
|
5666
|
-
const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
|
|
5667
|
-
let newContent = '';
|
|
5668
|
-
if (before) newContent += before + '\n\n';
|
|
5669
|
-
newContent += gsdBlock;
|
|
5670
|
-
if (after) newContent += '\n\n' + after;
|
|
5671
|
-
newContent += '\n';
|
|
5672
|
-
fs.writeFileSync(filePath, newContent);
|
|
5673
|
-
return;
|
|
5674
|
-
}
|
|
5572
|
+
*
|
|
5573
|
+
* This hook is a self-standing planning-artifact guard: it cancels write-class
|
|
5574
|
+
* tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
|
|
5575
|
+
* allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
|
|
5576
|
+
* hook bug can never wedge the user. No dependency on the #782 skills work.
|
|
5577
|
+
*/
|
|
5578
|
+
function buildClinePreToolUseHook() {
|
|
5579
|
+
return hooksSurface.buildClinePreToolUseHook();
|
|
5580
|
+
}
|
|
5675
5581
|
|
|
5676
|
-
|
|
5582
|
+
/**
|
|
5583
|
+
* Merge the GSD AGENTS.md block into an existing file (or create it), preserving
|
|
5584
|
+
* any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
|
|
5585
|
+
*/
|
|
5586
|
+
function mergeGsdAgentsMd(filePath, gsdContent) {
|
|
5587
|
+
return hooksSurface.mergeGsdAgentsMd(filePath, gsdContent);
|
|
5677
5588
|
}
|
|
5678
5589
|
|
|
5679
5590
|
/**
|
|
@@ -5703,53 +5614,7 @@ function stripGsdFromAgentsMd(content) {
|
|
|
5703
5614
|
* caller can hash-track them).
|
|
5704
5615
|
*/
|
|
5705
5616
|
function writeClineArtifacts(targetDir, isGlobalInstall) {
|
|
5706
|
-
|
|
5707
|
-
const clinerulesDir = path.join(targetDir, '.clinerules');
|
|
5708
|
-
|
|
5709
|
-
// Migrate a pre-#787 single-file `.clinerules` — a path cannot be both a
|
|
5710
|
-
// file and a directory, so the legacy file must be removed first. The legacy
|
|
5711
|
-
// file is GSD-authored (the installer wrote its full contents with no user
|
|
5712
|
-
// merge surface), so replacing it with the newer directory form is the
|
|
5713
|
-
// intended upgrade. Use lstat so a symlink is unlinked in place rather than
|
|
5714
|
-
// followed (which would write GSD files through the link into an external dir).
|
|
5715
|
-
try {
|
|
5716
|
-
if (fs.existsSync(clinerulesDir)) {
|
|
5717
|
-
const st = fs.lstatSync(clinerulesDir);
|
|
5718
|
-
if (st.isFile() || st.isSymbolicLink()) {
|
|
5719
|
-
fs.unlinkSync(clinerulesDir);
|
|
5720
|
-
console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
|
|
5721
|
-
}
|
|
5722
|
-
}
|
|
5723
|
-
} catch { /* best-effort migration */ }
|
|
5724
|
-
|
|
5725
|
-
fs.mkdirSync(clinerulesDir, { recursive: true });
|
|
5726
|
-
fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
|
|
5727
|
-
written.push('.clinerules/gsd.md');
|
|
5728
|
-
console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
|
|
5729
|
-
|
|
5730
|
-
const hooksDir = path.join(clinerulesDir, 'hooks');
|
|
5731
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
5732
|
-
const hookPath = path.join(hooksDir, 'PreToolUse');
|
|
5733
|
-
fs.writeFileSync(hookPath, buildClinePreToolUseHook());
|
|
5734
|
-
try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ }
|
|
5735
|
-
written.push('.clinerules/hooks/PreToolUse');
|
|
5736
|
-
console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
|
|
5737
|
-
|
|
5738
|
-
// Global cross-tool instruction target. Cline reads ~/.agents/AGENTS.md
|
|
5739
|
-
// (docs.cline.bot/customization/cline-rules). Merge-safe so we never clobber
|
|
5740
|
-
// a user's or another tool's AGENTS.md. Tracked via markers (like copilot),
|
|
5741
|
-
// not the per-configDir manifest, since it lives outside configDir.
|
|
5742
|
-
if (isGlobalInstall) {
|
|
5743
|
-
try {
|
|
5744
|
-
const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
|
|
5745
|
-
mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
|
|
5746
|
-
console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
|
|
5747
|
-
} catch (err) {
|
|
5748
|
-
console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`);
|
|
5749
|
-
}
|
|
5750
|
-
}
|
|
5751
|
-
|
|
5752
|
-
return written;
|
|
5617
|
+
return hooksSurface.writeClineArtifacts(targetDir, isGlobalInstall);
|
|
5753
5618
|
}
|
|
5754
5619
|
|
|
5755
5620
|
// ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
|
|
@@ -5779,11 +5644,7 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
|
|
|
5779
5644
|
* @returns {object} Cursor hook entry object
|
|
5780
5645
|
*/
|
|
5781
5646
|
function buildCursorHookEntry(scriptPath) {
|
|
5782
|
-
return
|
|
5783
|
-
type: 'command',
|
|
5784
|
-
command: scriptPath.replace(/\\/g, '/'),
|
|
5785
|
-
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
5786
|
-
};
|
|
5647
|
+
return hooksSurface.buildCursorHookEntry(scriptPath);
|
|
5787
5648
|
}
|
|
5788
5649
|
|
|
5789
5650
|
/**
|
|
@@ -5794,7 +5655,7 @@ function buildCursorHookEntry(scriptPath) {
|
|
|
5794
5655
|
* @returns {boolean}
|
|
5795
5656
|
*/
|
|
5796
5657
|
function isManagedCursorHookEntry(entry) {
|
|
5797
|
-
return
|
|
5658
|
+
return hooksSurface.isManagedCursorHookEntry(entry);
|
|
5798
5659
|
}
|
|
5799
5660
|
|
|
5800
5661
|
/**
|
|
@@ -5815,74 +5676,7 @@ function isManagedCursorHookEntry(entry) {
|
|
|
5815
5676
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
5816
5677
|
*/
|
|
5817
5678
|
function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
|
|
5818
|
-
|
|
5819
|
-
let currentContent = null;
|
|
5820
|
-
|
|
5821
|
-
if (fs.existsSync(hooksJsonPath)) {
|
|
5822
|
-
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
|
|
5823
|
-
currentContent = raw;
|
|
5824
|
-
if (raw.trim()) {
|
|
5825
|
-
try {
|
|
5826
|
-
parsed = JSON.parse(raw);
|
|
5827
|
-
} catch (err) {
|
|
5828
|
-
throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
|
|
5829
|
-
}
|
|
5830
|
-
}
|
|
5831
|
-
}
|
|
5832
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
|
|
5833
|
-
|
|
5834
|
-
// Cursor's canonical hooks.json schema is { "version": 1, "hooks": { ... } }.
|
|
5835
|
-
// GSD always writes (and migrates to) the nested shape so Cursor reads it correctly.
|
|
5836
|
-
// The flat shape { "sessionStart": [...] } is accepted on read for backwards compat
|
|
5837
|
-
// with manually-written files, but the output always uses the nested form.
|
|
5838
|
-
const hasNestedHooksObject =
|
|
5839
|
-
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
|
|
5840
|
-
if (!hasNestedHooksObject) {
|
|
5841
|
-
// Migrate flat shape (or empty {}) to nested: lift event keys into hooks:{}.
|
|
5842
|
-
const eventKeys = ['sessionStart', 'postToolUse'];
|
|
5843
|
-
const lifted = {};
|
|
5844
|
-
for (const k of eventKeys) {
|
|
5845
|
-
if (Array.isArray(parsed[k])) {
|
|
5846
|
-
lifted[k] = parsed[k];
|
|
5847
|
-
delete parsed[k];
|
|
5848
|
-
}
|
|
5849
|
-
}
|
|
5850
|
-
parsed.hooks = lifted;
|
|
5851
|
-
}
|
|
5852
|
-
if (!parsed.version) parsed.version = 1;
|
|
5853
|
-
const hookTable = parsed.hooks;
|
|
5854
|
-
|
|
5855
|
-
// Events GSD manages.
|
|
5856
|
-
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
|
|
5857
|
-
const entries = managedEntries || {};
|
|
5858
|
-
|
|
5859
|
-
for (const event of MANAGED_EVENTS) {
|
|
5860
|
-
const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
|
|
5861
|
-
// Strip all prior GSD-managed entries for this event.
|
|
5862
|
-
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
|
|
5863
|
-
const newEntry = entries[event] || null;
|
|
5864
|
-
if (newEntry) {
|
|
5865
|
-
hookTable[event] = [...userOwned, newEntry];
|
|
5866
|
-
} else {
|
|
5867
|
-
// Remove-only: keep user entries, or delete the key if it would be empty.
|
|
5868
|
-
if (userOwned.length > 0) {
|
|
5869
|
-
hookTable[event] = userOwned;
|
|
5870
|
-
} else {
|
|
5871
|
-
delete hookTable[event];
|
|
5872
|
-
}
|
|
5873
|
-
}
|
|
5874
|
-
}
|
|
5875
|
-
|
|
5876
|
-
// hookTable is parsed.hooks (always nested now); no reassignment needed.
|
|
5877
|
-
// Write only if content changed or if we're creating the file for the first time.
|
|
5878
|
-
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
5879
|
-
const changed = currentContent !== nextContent;
|
|
5880
|
-
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
5881
|
-
if (shouldWrite) {
|
|
5882
|
-
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
|
5883
|
-
}
|
|
5884
|
-
|
|
5885
|
-
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
|
|
5679
|
+
return hooksSurface.reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
|
5886
5680
|
}
|
|
5887
5681
|
|
|
5888
5682
|
/**
|
|
@@ -5898,65 +5692,7 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
|
|
|
5898
5692
|
* @returns {{ hooksJsonPath: string, changed: boolean }}
|
|
5899
5693
|
*/
|
|
5900
5694
|
function writeCursorHooksJson(targetDir, src, opts) {
|
|
5901
|
-
|
|
5902
|
-
const hooksDir = path.join(targetDir, 'hooks');
|
|
5903
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
5904
|
-
|
|
5905
|
-
// Copy the two GSD-managed hook scripts from the GSD source hooks/ directory.
|
|
5906
|
-
// Apply the same /gsd:/gi → gsd- rewrite used by copyWithPathReplacement for Cursor
|
|
5907
|
-
// JS files, so the installed hook scripts contain no /gsd: colon refs (bug-376 2b).
|
|
5908
|
-
// Track which scripts were successfully installed so we never register a hook entry
|
|
5909
|
-
// that references a script that wasn't copied (dangling command guard).
|
|
5910
|
-
const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
|
|
5911
|
-
const srcHooksDir = path.join(src, 'hooks');
|
|
5912
|
-
const installedScripts = new Set();
|
|
5913
|
-
for (const script of hookScripts) {
|
|
5914
|
-
const srcPath = path.join(srcHooksDir, script);
|
|
5915
|
-
const destPath = path.join(hooksDir, script);
|
|
5916
|
-
if (fs.existsSync(srcPath)) {
|
|
5917
|
-
let content = fs.readFileSync(srcPath, 'utf8');
|
|
5918
|
-
// Rewrite /gsd:<cmd> → gsd-<cmd> so installed hook scripts are consistent
|
|
5919
|
-
// with the Cursor convention (no colon-form slash commands in agent context).
|
|
5920
|
-
content = content.replace(/gsd:/gi, 'gsd-');
|
|
5921
|
-
fs.writeFileSync(destPath, content);
|
|
5922
|
-
try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
|
|
5923
|
-
installedScripts.add(script);
|
|
5924
|
-
}
|
|
5925
|
-
}
|
|
5926
|
-
|
|
5927
|
-
// Build command strings using the same buildHookCommand helper used by other runtimes.
|
|
5928
|
-
// buildHookCommand resolves the node runner + emits "<runner>" "<targetDir>/hooks/<name>".
|
|
5929
|
-
const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
|
|
5930
|
-
// buildHookCommand('gsd-cursor-session-start.js', ...): sessionStart → context injection
|
|
5931
|
-
// Only register the hook entry if the script was actually installed (dangling guard).
|
|
5932
|
-
const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
|
|
5933
|
-
? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
|
|
5934
|
-
: null;
|
|
5935
|
-
// buildHookCommand('gsd-cursor-post-tool.js', ...): postToolUse → STATE.md update monitor
|
|
5936
|
-
const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
|
|
5937
|
-
? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
|
|
5938
|
-
: null;
|
|
5939
|
-
|
|
5940
|
-
// Build managed entries; skip events whose command couldn't be resolved (e.g. no node).
|
|
5941
|
-
const managedEntries = {};
|
|
5942
|
-
if (sessionStartCmd) {
|
|
5943
|
-
managedEntries.sessionStart = {
|
|
5944
|
-
type: 'command',
|
|
5945
|
-
command: sessionStartCmd,
|
|
5946
|
-
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
5947
|
-
};
|
|
5948
|
-
}
|
|
5949
|
-
if (postToolCmd) {
|
|
5950
|
-
managedEntries.postToolUse = {
|
|
5951
|
-
type: 'command',
|
|
5952
|
-
command: postToolCmd,
|
|
5953
|
-
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
5954
|
-
};
|
|
5955
|
-
}
|
|
5956
|
-
|
|
5957
|
-
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
5958
|
-
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
|
5959
|
-
return { hooksJsonPath, changed: result.changed };
|
|
5695
|
+
return hooksSurface.writeCursorHooksJson(targetDir, src, opts);
|
|
5960
5696
|
}
|
|
5961
5697
|
|
|
5962
5698
|
/**
|
|
@@ -5967,31 +5703,7 @@ function writeCursorHooksJson(targetDir, src, opts) {
|
|
|
5967
5703
|
* @returns {{ changed: boolean }}
|
|
5968
5704
|
*/
|
|
5969
5705
|
function removeCursorHooksJson(targetDir) {
|
|
5970
|
-
|
|
5971
|
-
if (!fs.existsSync(hooksJsonPath)) return { changed: false };
|
|
5972
|
-
const result = reconcileCursorHooksJson(hooksJsonPath, null);
|
|
5973
|
-
// If the resulting file has no meaningful hook content, remove it.
|
|
5974
|
-
// A file is "empty" if it contains only the scaffolding (version, empty hooks
|
|
5975
|
-
// object, or a bare {}) with no user-authored hook entries.
|
|
5976
|
-
if (result.changed) {
|
|
5977
|
-
try {
|
|
5978
|
-
const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
|
|
5979
|
-
const parsed = JSON.parse(contentRaw);
|
|
5980
|
-
// reconcileCursorHooksJson always writes the nested { version, hooks:{} } shape.
|
|
5981
|
-
// The file is "empty" when there are no remaining hook events with entries.
|
|
5982
|
-
const hookTable = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
|
|
5983
|
-
? parsed.hooks
|
|
5984
|
-
: {};
|
|
5985
|
-
const hasAnyEvents = Object.keys(hookTable).some(
|
|
5986
|
-
(k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0,
|
|
5987
|
-
);
|
|
5988
|
-
if (!hasAnyEvents) {
|
|
5989
|
-
fs.unlinkSync(hooksJsonPath);
|
|
5990
|
-
return { changed: true };
|
|
5991
|
-
}
|
|
5992
|
-
} catch { /* best-effort: leave the file */ }
|
|
5993
|
-
}
|
|
5994
|
-
return { changed: result.changed };
|
|
5706
|
+
return hooksSurface.removeCursorHooksJson(targetDir);
|
|
5995
5707
|
}
|
|
5996
5708
|
|
|
5997
5709
|
/**
|
|
@@ -6009,19 +5721,7 @@ function removeCursorHooksJson(targetDir) {
|
|
|
6009
5721
|
* @returns {object} Copilot hooks-configuration object
|
|
6010
5722
|
*/
|
|
6011
5723
|
function buildCopilotHookConfig() {
|
|
6012
|
-
return
|
|
6013
|
-
version: 1,
|
|
6014
|
-
hooks: {
|
|
6015
|
-
sessionStart: [
|
|
6016
|
-
{
|
|
6017
|
-
type: 'command',
|
|
6018
|
-
bash: GSD_COPILOT_SESSION_HOOK_BASH,
|
|
6019
|
-
powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
|
|
6020
|
-
timeoutSec: 10,
|
|
6021
|
-
},
|
|
6022
|
-
],
|
|
6023
|
-
},
|
|
6024
|
-
};
|
|
5724
|
+
return hooksSurface.buildCopilotHookConfig();
|
|
6025
5725
|
}
|
|
6026
5726
|
|
|
6027
5727
|
/**
|
|
@@ -6038,11 +5738,7 @@ function buildCopilotHookConfig() {
|
|
|
6038
5738
|
* @returns {string} The path the hook config was written to
|
|
6039
5739
|
*/
|
|
6040
5740
|
function writeCopilotHookConfig(targetDir) {
|
|
6041
|
-
|
|
6042
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
6043
|
-
const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE);
|
|
6044
|
-
fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
|
|
6045
|
-
return hookPath;
|
|
5741
|
+
return hooksSurface.writeCopilotHookConfig(targetDir);
|
|
6046
5742
|
}
|
|
6047
5743
|
|
|
6048
5744
|
/**
|
|
@@ -7090,8 +6786,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
|
|
7090
6786
|
* @param {string} stagedDir
|
|
7091
6787
|
* @param {string} runtime
|
|
7092
6788
|
* @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
|
|
6789
|
+
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
|
|
7093
6790
|
*/
|
|
7094
|
-
function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
|
|
6791
|
+
function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
|
|
7095
6792
|
if (!fs.existsSync(stagedDir)) return;
|
|
7096
6793
|
|
|
7097
6794
|
// Walk all SKILL.md files under stagedDir
|
|
@@ -7100,9 +6797,9 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
|
|
|
7100
6797
|
const fullPath = path.join(dir, entry.name);
|
|
7101
6798
|
if (entry.isDirectory()) {
|
|
7102
6799
|
walkAndRewrite(fullPath);
|
|
7103
|
-
} else if (entry.name
|
|
6800
|
+
} else if (entry.name.endsWith('.md')) {
|
|
7104
6801
|
let content = fs.readFileSync(fullPath, 'utf8');
|
|
7105
|
-
content = _applyRuntimeRewrites(content, runtime, pathPrefix);
|
|
6802
|
+
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
|
|
7106
6803
|
fs.writeFileSync(fullPath, content);
|
|
7107
6804
|
}
|
|
7108
6805
|
}
|
|
@@ -7123,9 +6820,10 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
|
|
|
7123
6820
|
* @param {string} stagedDir directory of staged flat .md command files (may be source dir)
|
|
7124
6821
|
* @param {string} runtime
|
|
7125
6822
|
* @param {string} pathPrefix
|
|
6823
|
+
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
|
|
7126
6824
|
* @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
|
|
7127
6825
|
*/
|
|
7128
|
-
function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) {
|
|
6826
|
+
function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
|
|
7129
6827
|
if (!fs.existsSync(stagedDir)) return stagedDir;
|
|
7130
6828
|
// Always copy to a temp dir — stageSkillsForProfile() returns the original source
|
|
7131
6829
|
// dir on full/default profile (skills === '*'), so writing in-place would corrupt the
|
|
@@ -7135,7 +6833,7 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
|
|
|
7135
6833
|
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
|
|
7136
6834
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
7137
6835
|
let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
|
|
7138
|
-
content = _applyRuntimeRewrites(content, runtime, pathPrefix);
|
|
6836
|
+
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
|
|
7139
6837
|
// For augment commands, apply the markdown conversion so tool references
|
|
7140
6838
|
// and skill paths use Augment equivalents.
|
|
7141
6839
|
if (runtime === 'augment') {
|
|
@@ -7157,9 +6855,10 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
|
|
|
7157
6855
|
* @param {string} content
|
|
7158
6856
|
* @param {string} runtime
|
|
7159
6857
|
* @param {string} pathPrefix trailing-slash string
|
|
6858
|
+
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
|
|
7160
6859
|
* @returns {string}
|
|
7161
6860
|
*/
|
|
7162
|
-
function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
6861
|
+
function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) {
|
|
7163
6862
|
const dirName = getDirName(runtime);
|
|
7164
6863
|
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
|
7165
6864
|
|
|
@@ -7196,13 +6895,30 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
7196
6895
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
7197
6896
|
break;
|
|
7198
6897
|
|
|
7199
|
-
case 'windsurf':
|
|
6898
|
+
case 'windsurf': {
|
|
7200
6899
|
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
7201
6900
|
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
|
7202
6901
|
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
|
|
6902
|
+
// Bare forms (no trailing slash) — use (?![\w-]) instead of \b so that
|
|
6903
|
+
// .claude-plugin / .claudeignore are NOT corrupted (the \b word-boundary
|
|
6904
|
+
// fires between 'e' and '-', which rewrites .claude-plugin → .devin-plugin).
|
|
6905
|
+
content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
|
|
6906
|
+
content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
|
|
7203
6907
|
content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix);
|
|
6908
|
+
// Stage-1 converter rewrites .claude/skills/ → .devin/skills/ (workspace-relative
|
|
6909
|
+
// form). For global installs the real path is pathPrefix + skills/, so fix that up
|
|
6910
|
+
// here using the real isGlobal flag (threaded from installRuntimeArtifacts scope,
|
|
6911
|
+
// not derived from pathPrefix substring which misclassifies custom config dirs).
|
|
6912
|
+
// For local installs, the relative .devin/ form is correct — leave it. (#1085)
|
|
6913
|
+
if (isGlobal) {
|
|
6914
|
+
content = content.replace(/\.devin\/skills\//g, `${pathPrefix}skills/`);
|
|
6915
|
+
content = content.replace(/\.\/\.devin\//g, pathPrefix);
|
|
6916
|
+
content = content.replace(/~\/\.devin(?![\w-])/g, normalizedPathPrefix);
|
|
6917
|
+
content = content.replace(/\$HOME\/\.devin(?![\w-])/g, normalizedPathPrefix);
|
|
6918
|
+
}
|
|
7204
6919
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
7205
6920
|
break;
|
|
6921
|
+
}
|
|
7206
6922
|
|
|
7207
6923
|
case 'augment':
|
|
7208
6924
|
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
@@ -7291,6 +7007,16 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
7291
7007
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
7292
7008
|
break;
|
|
7293
7009
|
|
|
7010
|
+
case 'kimi':
|
|
7011
|
+
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
7012
|
+
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
|
7013
|
+
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
|
|
7014
|
+
content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
|
|
7015
|
+
content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
|
|
7016
|
+
content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
|
|
7017
|
+
content = processAttribution(content, getCommitAttribution(runtime));
|
|
7018
|
+
break;
|
|
7019
|
+
|
|
7294
7020
|
default:
|
|
7295
7021
|
// Unknown runtime — no rewrites.
|
|
7296
7022
|
// OpenCode/Kilo are intentionally absent: their skills are written by
|
|
@@ -7314,6 +7040,7 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
7314
7040
|
* encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in
|
|
7315
7041
|
* which case write as `${stem}.md` (directory IS the namespace).
|
|
7316
7042
|
* - agents: write as-is (files already carry their own `gsd-` prefix).
|
|
7043
|
+
* For kimi-agents kind: recursively copy generated YAML/prompt files.
|
|
7317
7044
|
*/
|
|
7318
7045
|
function _copyStaged(stagedDir, destDir, kind) {
|
|
7319
7046
|
if (!fs.existsSync(stagedDir)) return;
|
|
@@ -7330,6 +7057,11 @@ function _copyStaged(stagedDir, destDir, kind) {
|
|
|
7330
7057
|
return;
|
|
7331
7058
|
}
|
|
7332
7059
|
|
|
7060
|
+
if (kind.kind === 'kimi-agents') {
|
|
7061
|
+
fs.cpSync(stagedDir, destDir, { recursive: true });
|
|
7062
|
+
return;
|
|
7063
|
+
}
|
|
7064
|
+
|
|
7333
7065
|
// commands or agents
|
|
7334
7066
|
const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
|
|
7335
7067
|
// For commands: apply prefix unless the destSubpath's last segment already
|
|
@@ -7361,11 +7093,27 @@ function _copyStaged(stagedDir, destDir, kind) {
|
|
|
7361
7093
|
|
|
7362
7094
|
/**
|
|
7363
7095
|
* Remove GSD-prefixed entries from destDir matching kind.prefix.
|
|
7364
|
-
* For
|
|
7365
|
-
* (
|
|
7096
|
+
* For the prefix='' case: the destSubpath IS the namespace — remove the entire
|
|
7097
|
+
* destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
|
|
7098
|
+
* as a defensive guard for future runtimes.)
|
|
7366
7099
|
*/
|
|
7367
7100
|
function _removeGsdEntries(destDir, kind) {
|
|
7368
7101
|
if (!fs.existsSync(destDir)) return;
|
|
7102
|
+
if (kind.kind === 'kimi-agents') {
|
|
7103
|
+
for (const fileName of ['gsd.yaml', 'gsd.md']) {
|
|
7104
|
+
fs.rmSync(path.join(destDir, fileName), { force: true });
|
|
7105
|
+
}
|
|
7106
|
+
const subagentsDir = path.join(destDir, 'subagents');
|
|
7107
|
+
if (fs.existsSync(subagentsDir)) {
|
|
7108
|
+
for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) {
|
|
7109
|
+
if (!entry.isFile()) continue;
|
|
7110
|
+
if (!entry.name.startsWith('gsd-')) continue;
|
|
7111
|
+
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
|
|
7112
|
+
fs.rmSync(path.join(subagentsDir, entry.name), { force: true });
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7115
|
+
return;
|
|
7116
|
+
}
|
|
7369
7117
|
if (kind.prefix === '') {
|
|
7370
7118
|
// Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
|
|
7371
7119
|
// The directory itself is the GSD namespace, so remove it entirely.
|
|
@@ -7419,20 +7167,11 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
|
|
|
7419
7167
|
}
|
|
7420
7168
|
}
|
|
7421
7169
|
|
|
7422
|
-
// Hermes:
|
|
7423
|
-
//
|
|
7424
|
-
//
|
|
7425
|
-
//
|
|
7426
|
-
//
|
|
7427
|
-
// directory and any non-gsd- siblings (user content) are preserved.
|
|
7428
|
-
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
|
|
7429
|
-
if (fs.existsSync(nestedGsdDir)) {
|
|
7430
|
-
for (const entry of fs.readdirSync(nestedGsdDir, { withFileTypes: true })) {
|
|
7431
|
-
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
|
7432
|
-
fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
|
|
7433
|
-
}
|
|
7434
|
-
}
|
|
7435
|
-
}
|
|
7170
|
+
// Hermes: bare-stem skills/gsd/<stem>/ cleanup is deferred to AFTER the
|
|
7171
|
+
// layout-driven install loop in installRuntimeArtifacts, where the exact set
|
|
7172
|
+
// of staged gsd-<stem>/ dirs is known. Removing here (before staging) would
|
|
7173
|
+
// require readGsdCommandNames() which misses skills like 'dev-preferences'
|
|
7174
|
+
// that are not in the commands directory. See _removeHermesBareStemDirs().
|
|
7436
7175
|
}
|
|
7437
7176
|
|
|
7438
7177
|
// Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
|
|
@@ -7489,6 +7228,18 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
|
|
7489
7228
|
}
|
|
7490
7229
|
}
|
|
7491
7230
|
}
|
|
7231
|
+
|
|
7232
|
+
// Hermes: pre-#947 bare-stem skills/gsd/<stem>/ entries (dirs that do NOT
|
|
7233
|
+
// start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills
|
|
7234
|
+
// had bare names (e.g. skills/gsd/help/). These are stale on uninstall.
|
|
7235
|
+
const nestedGsdDirForUninstall = path.join(configDir, 'skills', 'gsd');
|
|
7236
|
+
if (fs.existsSync(nestedGsdDirForUninstall)) {
|
|
7237
|
+
for (const entry of fs.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) {
|
|
7238
|
+
if (entry.isDirectory() && !entry.name.startsWith('gsd-')) {
|
|
7239
|
+
fs.rmSync(path.join(nestedGsdDirForUninstall, entry.name), { recursive: true });
|
|
7240
|
+
}
|
|
7241
|
+
}
|
|
7242
|
+
}
|
|
7492
7243
|
}
|
|
7493
7244
|
|
|
7494
7245
|
// Return saved artifacts so the caller can migrate after layout-driven removal.
|
|
@@ -7539,6 +7290,43 @@ function _restoreDir(dir, snapshot) {
|
|
|
7539
7290
|
}
|
|
7540
7291
|
}
|
|
7541
7292
|
|
|
7293
|
+
/**
|
|
7294
|
+
* After the layout-driven install loop writes new gsd-<stem>/ dirs to
|
|
7295
|
+
* skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd/<stem>/)
|
|
7296
|
+
* that correspond to the newly installed gsd-<stem> entries.
|
|
7297
|
+
*
|
|
7298
|
+
* The removal set is derived from the ACTUAL installed skill dirs (every
|
|
7299
|
+
* entry starting with 'gsd-' that is a directory), so it covers ALL shipped
|
|
7300
|
+
* GSD skills — including 'dev-preferences' and future additions — without
|
|
7301
|
+
* relying on readGsdCommandNames() which only enumerates the commands source
|
|
7302
|
+
* tree and can miss skills that ship outside that directory.
|
|
7303
|
+
*
|
|
7304
|
+
* Safety: a bare dir is ONLY removed when a corresponding gsd-<stem>/ dir was
|
|
7305
|
+
* installed this run. A user-owned dir 'skills/gsd/my-workflow/' that has no
|
|
7306
|
+
* matching 'skills/gsd/gsd-my-workflow/' is never touched.
|
|
7307
|
+
*
|
|
7308
|
+
* @param {string} nestedGsdDir absolute path to skills/gsd/ category dir
|
|
7309
|
+
*/
|
|
7310
|
+
function _removeHermesBareStemDirs(nestedGsdDir) {
|
|
7311
|
+
if (!fs.existsSync(nestedGsdDir)) return;
|
|
7312
|
+
const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
|
|
7313
|
+
|
|
7314
|
+
// Collect the set of stems that were installed as gsd-<stem>/ this run.
|
|
7315
|
+
const installedStems = new Set();
|
|
7316
|
+
for (const entry of entries) {
|
|
7317
|
+
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
|
7318
|
+
installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences'
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
|
|
7322
|
+
// Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
|
|
7323
|
+
for (const entry of entries) {
|
|
7324
|
+
if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
|
|
7325
|
+
fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
|
|
7326
|
+
}
|
|
7327
|
+
}
|
|
7328
|
+
}
|
|
7329
|
+
|
|
7542
7330
|
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
7543
7331
|
// Legacy cleanup before layout-driven writes
|
|
7544
7332
|
_runLegacyInstallMigrations(runtime, configDir, scope);
|
|
@@ -7562,11 +7350,12 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
7562
7350
|
// stagedForCopy: the directory to copy from (may differ from staged if rewrites
|
|
7563
7351
|
// produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
|
|
7564
7352
|
let stagedForCopy = staged;
|
|
7565
|
-
|
|
7566
|
-
|
|
7353
|
+
const isGlobal = scope === 'global';
|
|
7354
|
+
if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
|
|
7355
|
+
applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal);
|
|
7567
7356
|
} else if (kind.kind === 'commands') {
|
|
7568
7357
|
// Returns a temp dir with rewritten content so source files are never mutated.
|
|
7569
|
-
stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix);
|
|
7358
|
+
stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal);
|
|
7570
7359
|
}
|
|
7571
7360
|
// applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under
|
|
7572
7361
|
// os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856).
|
|
@@ -7579,29 +7368,15 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
7579
7368
|
// then restore after. This preserves user dirs across a wipe-and-replace
|
|
7580
7369
|
// install (#2973 / #3664).
|
|
7581
7370
|
//
|
|
7582
|
-
//
|
|
7583
|
-
//
|
|
7584
|
-
//
|
|
7585
|
-
//
|
|
7586
|
-
//
|
|
7587
|
-
// Non-gsd-* user dirs (e.g. my-custom-skill/) are untouched. Only preserve the
|
|
7588
|
-
// explicit user-owned GSD-prefixed skill gsd-dev-preferences, which GSD does not
|
|
7589
|
-
// reinstall from source but must survive the prune (#2973).
|
|
7371
|
+
// All runtimes (incl. Hermes after #947) use prefix='gsd-'.
|
|
7372
|
+
// _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
|
|
7373
|
+
// untouched. Preserve the explicit user-owned GSD-prefixed skill
|
|
7374
|
+
// gsd-dev-preferences, which GSD does not reinstall from source but must
|
|
7375
|
+
// survive the prune (#2973).
|
|
7590
7376
|
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
|
|
7591
7377
|
|
|
7592
|
-
|
|
7593
|
-
//
|
|
7594
|
-
const stagedNames = fs.existsSync(stagedForCopy)
|
|
7595
|
-
? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true })
|
|
7596
|
-
.filter(e => e.isDirectory()).map(e => e.name))
|
|
7597
|
-
: new Set();
|
|
7598
|
-
for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
|
|
7599
|
-
if (!entry.isDirectory() || stagedNames.has(entry.name)) continue;
|
|
7600
|
-
const snap = _snapshotDir(path.join(dest, entry.name));
|
|
7601
|
-
if (snap.size > 0) toPreserve.set(entry.name, snap);
|
|
7602
|
-
}
|
|
7603
|
-
} else {
|
|
7604
|
-
// Non-Hermes: only preserve explicitly user-owned GSD-prefixed skill dirs.
|
|
7378
|
+
{
|
|
7379
|
+
// Preserve explicitly user-owned GSD-prefixed skill dirs.
|
|
7605
7380
|
// gsd-dev-preferences is the sole user-customisable skill in this category.
|
|
7606
7381
|
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
|
7607
7382
|
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
|
@@ -7631,6 +7406,21 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
7631
7406
|
}
|
|
7632
7407
|
}
|
|
7633
7408
|
}
|
|
7409
|
+
|
|
7410
|
+
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
|
|
7411
|
+
// skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
|
|
7412
|
+
// correspond to the newly installed gsd-<stem> entries. This is the robust
|
|
7413
|
+
// replacement for the readGsdCommandNames()-based pre-install cleanup that
|
|
7414
|
+
// missed skills like 'dev-preferences' (#947 adversarial review).
|
|
7415
|
+
//
|
|
7416
|
+
// We run this AFTER the install loop so the installed set is authoritative:
|
|
7417
|
+
// every gsd-<stem>/ present now was written this run (or was there before
|
|
7418
|
+
// with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
|
|
7419
|
+
// are untouched.
|
|
7420
|
+
if (runtime === 'hermes') {
|
|
7421
|
+
const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
|
|
7422
|
+
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
|
|
7423
|
+
}
|
|
7634
7424
|
}
|
|
7635
7425
|
|
|
7636
7426
|
/**
|
|
@@ -7736,6 +7526,23 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
|
|
|
7736
7526
|
_removeGsdEntries(dest, kind);
|
|
7737
7527
|
}
|
|
7738
7528
|
|
|
7529
|
+
// Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
|
|
7530
|
+
// the GSD-managed DESCRIPTION.md and then the category dir itself if it
|
|
7531
|
+
// contains no user content (#947). _removeGsdEntries removed gsd-* dirs
|
|
7532
|
+
// but left the category container and DESCRIPTION.md intact.
|
|
7533
|
+
if (runtime === 'hermes') {
|
|
7534
|
+
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
|
|
7535
|
+
if (fs.existsSync(nestedGsdDir)) {
|
|
7536
|
+
// Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
|
|
7537
|
+
fs.rmSync(path.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true });
|
|
7538
|
+
// Remove the category dir if empty (no user content remaining)
|
|
7539
|
+
const remaining = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
|
|
7540
|
+
if (remaining.length === 0) {
|
|
7541
|
+
fs.rmSync(nestedGsdDir, { recursive: true, force: true });
|
|
7542
|
+
}
|
|
7543
|
+
}
|
|
7544
|
+
}
|
|
7545
|
+
|
|
7739
7546
|
// #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
|
|
7740
7547
|
// runtime-aware SKILL.md location after all layout-driven removal is
|
|
7741
7548
|
// complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
|
|
@@ -7795,6 +7602,9 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
|
|
7795
7602
|
content = content.replace(globalClaudeRegex, pathPrefix);
|
|
7796
7603
|
content = content.replace(globalClaudeHomeRegex, pathPrefix);
|
|
7797
7604
|
content = content.replace(localClaudeRegex, `./${dirName}/`);
|
|
7605
|
+
content = content.replace(/~\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
|
|
7606
|
+
content = content.replace(/\$HOME\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
|
|
7607
|
+
content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
|
|
7798
7608
|
content = content.replace(/~\/\.qwen\//g, pathPrefix);
|
|
7799
7609
|
content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
|
|
7800
7610
|
content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`);
|
|
@@ -7880,11 +7690,12 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
|
|
7880
7690
|
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor');
|
|
7881
7691
|
fs.writeFileSync(destPath, jsContent);
|
|
7882
7692
|
} else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
|
|
7883
|
-
// For Windsurf, also convert Claude references in JS/CJS utility scripts
|
|
7693
|
+
// For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts.
|
|
7694
|
+
// Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
|
|
7884
7695
|
let jsContent = fs.readFileSync(srcPath, 'utf8');
|
|
7885
7696
|
jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
|
|
7886
|
-
jsContent = jsContent.replace(/\.claude\/skills\//g, '.
|
|
7887
|
-
jsContent = jsContent.replace(/CLAUDE\.md/g, '.
|
|
7697
|
+
jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/');
|
|
7698
|
+
jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules');
|
|
7888
7699
|
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf');
|
|
7889
7700
|
fs.writeFileSync(destPath, jsContent);
|
|
7890
7701
|
} else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
|
|
@@ -8130,6 +7941,7 @@ function uninstall(isGlobal, runtime = 'claude') {
|
|
|
8130
7941
|
if (runtime === 'trae') runtimeLabel = 'Trae';
|
|
8131
7942
|
if (runtime === 'qwen') runtimeLabel = 'Qwen Code';
|
|
8132
7943
|
if (runtime === 'hermes') runtimeLabel = 'Hermes Agent';
|
|
7944
|
+
if (runtime === 'kimi') runtimeLabel = 'Kimi CLI';
|
|
8133
7945
|
if (runtime === 'codebuddy') runtimeLabel = 'CodeBuddy';
|
|
8134
7946
|
|
|
8135
7947
|
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
|
|
@@ -9109,6 +8921,7 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
|
|
9109
8921
|
const isWindsurf = runtime === 'windsurf';
|
|
9110
8922
|
const isTrae = runtime === 'trae';
|
|
9111
8923
|
const isCline = runtime === 'cline';
|
|
8924
|
+
const isKimi = runtime === 'kimi';
|
|
9112
8925
|
const isHermes = runtime === 'hermes';
|
|
9113
8926
|
const gsdDir = path.join(configDir, 'gsd-core');
|
|
9114
8927
|
const commandsDir = path.join(configDir, 'commands', 'gsd');
|
|
@@ -9156,8 +8969,8 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
|
|
9156
8969
|
}
|
|
9157
8970
|
}
|
|
9158
8971
|
if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || (!isOpencode && !isGemini)) && fs.existsSync(codexSkillsDir)) {
|
|
9159
|
-
//
|
|
9160
|
-
const skillListPrefix =
|
|
8972
|
+
// All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix.
|
|
8973
|
+
const skillListPrefix = 'gsd-';
|
|
9161
8974
|
for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
|
|
9162
8975
|
const skillRoot = path.join(codexSkillsDir, skillName);
|
|
9163
8976
|
const skillHashes = generateManifest(skillRoot);
|
|
@@ -9173,7 +8986,16 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
|
|
9173
8986
|
}
|
|
9174
8987
|
}
|
|
9175
8988
|
}
|
|
9176
|
-
if (fs.existsSync(agentsDir)) {
|
|
8989
|
+
if (isKimi && fs.existsSync(agentsDir)) {
|
|
8990
|
+
const agentHashes = generateManifest(agentsDir);
|
|
8991
|
+
for (const [rel, hash] of Object.entries(agentHashes)) {
|
|
8992
|
+
const isRootAgent = rel === 'gsd.yaml' || rel === 'gsd.md';
|
|
8993
|
+
const isSubagent = /^subagents\/gsd-[^/]+\.(yaml|md)$/.test(rel);
|
|
8994
|
+
if (isRootAgent || isSubagent) {
|
|
8995
|
+
manifest.files['agents/' + rel] = hash;
|
|
8996
|
+
}
|
|
8997
|
+
}
|
|
8998
|
+
} else if (fs.existsSync(agentsDir)) {
|
|
9177
8999
|
for (const file of fs.readdirSync(agentsDir)) {
|
|
9178
9000
|
if (file.startsWith('gsd-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
|
|
9179
9001
|
manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
|
|
@@ -9194,12 +9016,20 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
|
|
9194
9016
|
|
|
9195
9017
|
// Track hook files so saveLocalPatches() can detect user modifications
|
|
9196
9018
|
// Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
|
|
9197
|
-
if (!isCodex && !isCopilot && !isCline) {
|
|
9019
|
+
if (!isCodex && !isCopilot && !isCline && !isKimi) {
|
|
9198
9020
|
const hooksDir = path.join(configDir, 'hooks');
|
|
9199
9021
|
if (fs.existsSync(hooksDir)) {
|
|
9200
|
-
|
|
9201
|
-
|
|
9202
|
-
|
|
9022
|
+
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
|
|
9023
|
+
// scripts/build-hooks.js) rather than a prefix/extension regex, so the
|
|
9024
|
+
// manifest set is structurally identical to the build set. The old regex
|
|
9025
|
+
// `file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))`
|
|
9026
|
+
// missed managed-hooks-registry.cjs (wrong prefix, .cjs extension), causing
|
|
9027
|
+
// detect-custom-files to flag it as a perpetual false-positive custom file
|
|
9028
|
+
// on every /gsd-update. See #941.
|
|
9029
|
+
for (const hook of INSTALLED_HOOK_FILES) {
|
|
9030
|
+
const hookPath = path.join(hooksDir, hook);
|
|
9031
|
+
if (fs.existsSync(hookPath)) {
|
|
9032
|
+
manifest.files['hooks/' + hook] = fileHash(hookPath);
|
|
9203
9033
|
}
|
|
9204
9034
|
}
|
|
9205
9035
|
// Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
|
|
@@ -9530,6 +9360,8 @@ function reportLocalPatches(configDir, runtime = 'claude') {
|
|
|
9530
9360
|
? '$gsd-update --reapply'
|
|
9531
9361
|
: runtime === 'cursor'
|
|
9532
9362
|
? 'gsd-update --reapply (mention the skill name)'
|
|
9363
|
+
: runtime === 'kimi'
|
|
9364
|
+
? '/skill:gsd-update --reapply'
|
|
9533
9365
|
: '/gsd-update --reapply';
|
|
9534
9366
|
console.log('');
|
|
9535
9367
|
console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
|
|
@@ -9560,6 +9392,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9560
9392
|
const isOpencode = runtime === 'opencode';
|
|
9561
9393
|
const isGemini = runtime === 'gemini';
|
|
9562
9394
|
const isKilo = runtime === 'kilo';
|
|
9395
|
+
const isKimi = runtime === 'kimi';
|
|
9563
9396
|
const isCodex = runtime === 'codex';
|
|
9564
9397
|
const isCopilot = runtime === 'copilot';
|
|
9565
9398
|
const isAntigravity = runtime === 'antigravity';
|
|
@@ -9571,10 +9404,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9571
9404
|
const isHermes = runtime === 'hermes';
|
|
9572
9405
|
const isCodebuddy = runtime === 'codebuddy';
|
|
9573
9406
|
const isCline = runtime === 'cline';
|
|
9574
|
-
const
|
|
9407
|
+
const plan = resolveInstallPlan(runtime);
|
|
9575
9408
|
const dirName = getDirName(runtime);
|
|
9576
9409
|
const src = path.join(__dirname, '..');
|
|
9577
9410
|
|
|
9411
|
+
if (isKimi && !isGlobal) {
|
|
9412
|
+
console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
|
|
9413
|
+
console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
|
|
9414
|
+
console.log(` Project-level Kimi install semantics remain deferred.`);
|
|
9415
|
+
return {
|
|
9416
|
+
runtime,
|
|
9417
|
+
skipped: true,
|
|
9418
|
+
reason: 'kimi_local_deferred',
|
|
9419
|
+
configDir: null,
|
|
9420
|
+
settingsPath: null,
|
|
9421
|
+
settings: null,
|
|
9422
|
+
statuslineCommand: null,
|
|
9423
|
+
updateBannerCommand: null,
|
|
9424
|
+
rollbackInstallerMigrations: () => {},
|
|
9425
|
+
};
|
|
9426
|
+
}
|
|
9427
|
+
|
|
9578
9428
|
// Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
|
|
9579
9429
|
// Defined early so it is visible to both the main and Codex code paths.
|
|
9580
9430
|
// `allowlist` (when non-empty) restricts copying to the named top-level entries,
|
|
@@ -9608,6 +9458,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9608
9458
|
// Get the target directory based on runtime and install type.
|
|
9609
9459
|
// Cline local installs write to the project root (like Claude Code) — .clinerules
|
|
9610
9460
|
// lives at the root, not inside a .cline/ subdirectory.
|
|
9461
|
+
// #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
|
|
9462
|
+
// directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
|
|
9463
|
+
// but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
|
|
9464
|
+
// auto-removed on reinstall (dual-read fallback per issue #791 spec).
|
|
9611
9465
|
const targetDir = isGlobal
|
|
9612
9466
|
? getGlobalConfigDir(runtime, explicitConfigDir)
|
|
9613
9467
|
: isCline
|
|
@@ -9629,7 +9483,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9629
9483
|
// @-references resolve correctly (#2376 Windows, #2831 macOS/Linux).
|
|
9630
9484
|
// gsd update marker re-application (ADR-0010 Deviation 2):
|
|
9631
9485
|
// Resolve which profile to use for this runtime's install:
|
|
9632
|
-
// 1. --minimal / --core-only → back-compat
|
|
9486
|
+
// 1. --minimal / --core-only → back-compat alias for the core profile
|
|
9633
9487
|
// 2. Explicit --profile=<name> → use it (overrides any marker)
|
|
9634
9488
|
// 3. Marker exists in targetDir → honor it (prevents silent expansion on update)
|
|
9635
9489
|
// 4. Else → 'full' (back-compat for fresh non-interactive installs)
|
|
@@ -9638,8 +9492,16 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9638
9492
|
// differ, the caller may use mostRestrictiveProfile() across the per-runtime
|
|
9639
9493
|
// results — here we resolve each runtime independently.
|
|
9640
9494
|
//
|
|
9641
|
-
//
|
|
9642
|
-
//
|
|
9495
|
+
// ADR-857 phase 4c: ALL profiles (including core/minimal) use stageSkillsForProfile
|
|
9496
|
+
// with the registry-aware _resolvedProfile so future tier:core capabilities are
|
|
9497
|
+
// staged on core installs. The 'minimal' back-compat distinction is now ONLY the
|
|
9498
|
+
// empty manifest (core profile has no transitive deps); the registry IS consulted.
|
|
9499
|
+
// MINIMAL is intentionally the same skill set as the 'core' profile
|
|
9500
|
+
// (MINIMAL_ALLOWLIST_SET === Set(PROFILES.core)) — it is NOT a separately curated
|
|
9501
|
+
// subset. Any future tier:core capability therefore DOES belong in a minimal/core
|
|
9502
|
+
// install. Using stageSkillsForProfile(_resolvedProfile) honors the registry while
|
|
9503
|
+
// keeping the effective skill set identical to the prior stageSkillsForMode path
|
|
9504
|
+
// until a tier:core capability is registered.
|
|
9643
9505
|
const _activeProfileName = hasMinimal
|
|
9644
9506
|
? 'core' // --minimal is a back-compat alias for the core profile; marker records 'core'
|
|
9645
9507
|
: resolveEffectiveProfile({
|
|
@@ -9650,19 +9512,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9650
9512
|
const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full';
|
|
9651
9513
|
// Load the manifest and compute resolved profile for named profiles.
|
|
9652
9514
|
// For --minimal/core: use an empty manifest (core profile has no transitive
|
|
9653
|
-
// deps) to produce a resolvedProfile with the core skill set.
|
|
9654
|
-
//
|
|
9655
|
-
// profile modes without a null sentinel.
|
|
9515
|
+
// deps) to produce a resolvedProfile with the core skill set. Registry IS
|
|
9516
|
+
// consulted so tier:core capability skills are included when registered.
|
|
9656
9517
|
const _commandsDir = path.join(src, 'commands', 'gsd');
|
|
9657
9518
|
const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
|
|
9658
9519
|
const _resolvedProfile = resolveProfile({
|
|
9659
9520
|
modes: [_activeProfileName],
|
|
9660
9521
|
manifest: _skillsManifest,
|
|
9522
|
+
registry: _capabilityRegistry,
|
|
9661
9523
|
});
|
|
9662
|
-
// Unified staging function:
|
|
9663
|
-
//
|
|
9524
|
+
// Unified staging function: all profiles use stageSkillsForProfile with the
|
|
9525
|
+
// registry-aware _resolvedProfile (ADR-857 phase 4c cutover).
|
|
9664
9526
|
function _stageSkills(commandsGsdDir) {
|
|
9665
|
-
if (_isCoreProfileAlias) return stageSkillsForMode(commandsGsdDir, _effectiveInstallMode);
|
|
9666
9527
|
return stageSkillsForProfile(commandsGsdDir, _resolvedProfile);
|
|
9667
9528
|
}
|
|
9668
9529
|
function _stageAgents(agentsDir) {
|
|
@@ -9701,6 +9562,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9701
9562
|
if (isTrae) runtimeLabel = 'Trae';
|
|
9702
9563
|
if (isQwen) runtimeLabel = 'Qwen Code';
|
|
9703
9564
|
if (isHermes) runtimeLabel = 'Hermes Agent';
|
|
9565
|
+
if (isKimi) runtimeLabel = 'Kimi';
|
|
9704
9566
|
if (isCodebuddy) runtimeLabel = 'CodeBuddy';
|
|
9705
9567
|
if (isCline) runtimeLabel = 'Cline';
|
|
9706
9568
|
|
|
@@ -9990,6 +9852,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
9990
9852
|
// Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782).
|
|
9991
9853
|
const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
|
|
9992
9854
|
isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
|
|
9855
|
+
isKimi ||
|
|
9993
9856
|
(runtime === 'claude' && isGlobal) ||
|
|
9994
9857
|
(isCline && isGlobal);
|
|
9995
9858
|
|
|
@@ -10017,9 +9880,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10017
9880
|
if (isHermes) {
|
|
10018
9881
|
const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd');
|
|
10019
9882
|
if (fs.existsSync(hermesSkillsDir)) {
|
|
10020
|
-
// Hermes layout uses prefix: '' — skill dirs have
|
|
9883
|
+
// Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd-<stem> names
|
|
10021
9884
|
const count = fs.readdirSync(hermesSkillsDir, { withFileTypes: true })
|
|
10022
|
-
.filter(e => e.isDirectory()).length;
|
|
9885
|
+
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
|
|
10023
9886
|
if (count > 0) {
|
|
10024
9887
|
console.log(` ${green}✓${reset} Installed ${count} skills to skills/gsd/`);
|
|
10025
9888
|
} else {
|
|
@@ -10028,6 +9891,26 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10028
9891
|
} else {
|
|
10029
9892
|
failures.push('skills/gsd/*');
|
|
10030
9893
|
}
|
|
9894
|
+
} else if (isKimi) {
|
|
9895
|
+
const skillsDir = path.join(targetDir, 'skills');
|
|
9896
|
+
const rootAgentPath = path.join(targetDir, 'agents', 'gsd.yaml');
|
|
9897
|
+
if (fs.existsSync(skillsDir)) {
|
|
9898
|
+
const count = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
9899
|
+
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
|
|
9900
|
+
if (count > 0) {
|
|
9901
|
+
console.log(` ${green}✓${reset} Installed ${count} Kimi skills to skills/`);
|
|
9902
|
+
} else {
|
|
9903
|
+
failures.push('skills/gsd-*');
|
|
9904
|
+
}
|
|
9905
|
+
} else {
|
|
9906
|
+
failures.push('skills/gsd-*');
|
|
9907
|
+
}
|
|
9908
|
+
if (fs.existsSync(rootAgentPath)) {
|
|
9909
|
+
console.log(` ${green}✓${reset} Generated Kimi root agent: ${rootAgentPath}`);
|
|
9910
|
+
console.log(` Launch with: kimi --agent-file ${rootAgentPath}`);
|
|
9911
|
+
} else {
|
|
9912
|
+
failures.push('agents/gsd.yaml');
|
|
9913
|
+
}
|
|
10031
9914
|
} else {
|
|
10032
9915
|
const skillsDir = path.join(targetDir, 'skills');
|
|
10033
9916
|
if (fs.existsSync(skillsDir)) {
|
|
@@ -10264,7 +10147,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10264
10147
|
}
|
|
10265
10148
|
}
|
|
10266
10149
|
|
|
10267
|
-
if (
|
|
10150
|
+
if (isKimi) {
|
|
10151
|
+
console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`);
|
|
10152
|
+
} else if (isMinimalMode(_effectiveInstallMode)) {
|
|
10268
10153
|
// Codex registers agents in `config.toml` via `[agents.gsd-*]` sections.
|
|
10269
10154
|
// Without stripping them here, a full → minimal reinstall would leave the
|
|
10270
10155
|
// runtime advertising the old full agent surface even though the agent
|
|
@@ -10367,6 +10252,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10367
10252
|
const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
|
|
10368
10253
|
const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
|
|
10369
10254
|
content = injectEffortFrontmatter(content, _renderedEffort);
|
|
10255
|
+
const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
|
|
10256
|
+
if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
|
|
10370
10257
|
}
|
|
10371
10258
|
// #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
|
|
10372
10259
|
// to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`
|
|
@@ -10407,7 +10294,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10407
10294
|
failures.push('VERSION');
|
|
10408
10295
|
}
|
|
10409
10296
|
|
|
10410
|
-
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) {
|
|
10297
|
+
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi) {
|
|
10411
10298
|
// Write package.json to force CommonJS mode for GSD scripts
|
|
10412
10299
|
// Prevents "require is not defined" errors when project has "type": "module"
|
|
10413
10300
|
// Node.js walks up looking for package.json - this stops inheritance from project
|
|
@@ -10503,7 +10390,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10503
10390
|
// the hooks/lib/ helpers — otherwise the Codex comment downstream
|
|
10504
10391
|
// ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
|
|
10505
10392
|
const hooksLibSrc = path.join(src, 'hooks', 'lib');
|
|
10506
|
-
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
|
|
10393
|
+
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && fs.existsSync(hooksLibSrc)) {
|
|
10507
10394
|
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
|
|
10508
10395
|
fs.mkdirSync(hooksLibDest, { recursive: true });
|
|
10509
10396
|
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
|
|
@@ -10664,7 +10551,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10664
10551
|
throw _earlyInstallErr;
|
|
10665
10552
|
}
|
|
10666
10553
|
|
|
10667
|
-
if (
|
|
10554
|
+
if (plan.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
|
|
10668
10555
|
// Capture pre-install snapshots before ANY GSD mutation
|
|
10669
10556
|
// (#2760 fix 3). On post-write schema-validation failure OR any throw
|
|
10670
10557
|
// during the mutation sequence (write failure, merge throw, etc.) we
|
|
@@ -11043,7 +10930,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11043
10930
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
11044
10931
|
}
|
|
11045
10932
|
|
|
11046
|
-
if (
|
|
10933
|
+
if (plan.installSurface === 'copilot-instructions') {
|
|
11047
10934
|
// Generate copilot-instructions.md
|
|
11048
10935
|
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
|
|
11049
10936
|
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
|
|
@@ -11073,7 +10960,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11073
10960
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
11074
10961
|
}
|
|
11075
10962
|
|
|
11076
|
-
if (
|
|
10963
|
+
if (plan.installSurface === 'cursor-hooks-json') {
|
|
11077
10964
|
// #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
|
|
11078
10965
|
// Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
|
|
11079
10966
|
const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
|
|
@@ -11088,13 +10975,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11088
10975
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
11089
10976
|
}
|
|
11090
10977
|
|
|
11091
|
-
if (
|
|
11092
|
-
// Windsurf/Trae use
|
|
10978
|
+
if (plan.installSurface === 'profile-marker-only') {
|
|
10979
|
+
// Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed.
|
|
11093
10980
|
persistActiveProfileMarker();
|
|
11094
10981
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
11095
10982
|
}
|
|
11096
10983
|
|
|
11097
|
-
if (
|
|
10984
|
+
if (plan.installSurface === 'cline-rules') {
|
|
11098
10985
|
// Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
|
|
11099
10986
|
// at .clinerules/gsd.md and a PreToolUse lifecycle hook at
|
|
11100
10987
|
// .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
|
|
@@ -11107,8 +10994,12 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11107
10994
|
}
|
|
11108
10995
|
|
|
11109
10996
|
// Configure statusline and hooks in settings.json (or settings.local.json for local Claude installs).
|
|
11110
|
-
//
|
|
11111
|
-
|
|
10997
|
+
// ADR-857 phase 5f-2: drive the hook event dialect from the registry descriptor.
|
|
10998
|
+
// runtimes with hookEvents='gemini' use AfterTool/BeforeTool; all others use PostToolUse/PreToolUse.
|
|
10999
|
+
// Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} — identical to the old check.
|
|
11000
|
+
// A missing registry or missing descriptor defaults to 'not gemini' → PostToolUse (safe).
|
|
11001
|
+
const _hookEventsDialect = plan.hookEvents;
|
|
11002
|
+
const postToolEvent = _hookEventsDialect === 'gemini' ? 'AfterTool' : 'PostToolUse';
|
|
11112
11003
|
// #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
|
|
11113
11004
|
// so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
|
|
11114
11005
|
// settings.json. Global installs and all other runtimes continue to use settings.json.
|
|
@@ -11282,464 +11173,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11282
11173
|
}
|
|
11283
11174
|
}
|
|
11284
11175
|
|
|
11285
|
-
//
|
|
11286
|
-
|
|
11287
|
-
|
|
11288
|
-
|
|
11289
|
-
|
|
11290
|
-
|
|
11291
|
-
|
|
11292
|
-
|
|
11293
|
-
|
|
11294
|
-
|
|
11295
|
-
|
|
11296
|
-
|
|
11297
|
-
|
|
11298
|
-
|
|
11299
|
-
|
|
11300
|
-
|
|
11301
|
-
|
|
11302
|
-
|
|
11303
|
-
|
|
11304
|
-
|
|
11305
|
-
|
|
11306
|
-
{
|
|
11307
|
-
type: 'command',
|
|
11308
|
-
command: updateCheckCommand
|
|
11309
|
-
}
|
|
11310
|
-
]
|
|
11311
|
-
});
|
|
11312
|
-
console.log(` ${green}✓${reset} Configured update check hook`);
|
|
11313
|
-
} else if (!hasGsdUpdateHook && !fs.existsSync(checkUpdateFile)) {
|
|
11314
|
-
console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`);
|
|
11315
|
-
}
|
|
11316
|
-
|
|
11317
|
-
// Configure post-tool hook for context window monitoring
|
|
11318
|
-
if (!settings.hooks[postToolEvent]) {
|
|
11319
|
-
settings.hooks[postToolEvent] = [];
|
|
11320
|
-
}
|
|
11321
|
-
|
|
11322
|
-
const hasContextMonitorHook = settings.hooks[postToolEvent].some(entry =>
|
|
11323
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
|
|
11324
|
-
);
|
|
11325
|
-
|
|
11326
|
-
const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
|
|
11327
|
-
if (!hasContextMonitorHook && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
11328
|
-
settings.hooks[postToolEvent].push({
|
|
11329
|
-
matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
|
|
11330
|
-
hooks: [
|
|
11331
|
-
{
|
|
11332
|
-
type: 'command',
|
|
11333
|
-
command: contextMonitorCommand,
|
|
11334
|
-
timeout: 10
|
|
11335
|
-
}
|
|
11336
|
-
]
|
|
11337
|
-
});
|
|
11338
|
-
console.log(` ${green}✓${reset} Configured context window monitor hook`);
|
|
11339
|
-
} else if (!hasContextMonitorHook && !fs.existsSync(contextMonitorFile)) {
|
|
11340
|
-
console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`);
|
|
11341
|
-
} else {
|
|
11342
|
-
// Migrate existing context monitor hooks: add matcher and timeout if missing
|
|
11343
|
-
for (const entry of settings.hooks[postToolEvent]) {
|
|
11344
|
-
if (entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))) {
|
|
11345
|
-
let migrated = false;
|
|
11346
|
-
if (!entry.matcher) {
|
|
11347
|
-
entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task';
|
|
11348
|
-
migrated = true;
|
|
11349
|
-
}
|
|
11350
|
-
for (const h of entry.hooks) {
|
|
11351
|
-
if (h.command && h.command.includes('gsd-context-monitor') && !h.timeout) {
|
|
11352
|
-
h.timeout = 10;
|
|
11353
|
-
migrated = true;
|
|
11354
|
-
}
|
|
11355
|
-
}
|
|
11356
|
-
if (migrated) {
|
|
11357
|
-
console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`);
|
|
11358
|
-
}
|
|
11359
|
-
}
|
|
11360
|
-
}
|
|
11361
|
-
}
|
|
11362
|
-
|
|
11363
|
-
// Configure PreToolUse hook for prompt injection detection
|
|
11364
|
-
// Gemini and Antigravity use BeforeTool instead of PreToolUse for pre-tool hooks
|
|
11365
|
-
const preToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse';
|
|
11366
|
-
if (!settings.hooks[preToolEvent]) {
|
|
11367
|
-
settings.hooks[preToolEvent] = [];
|
|
11368
|
-
}
|
|
11369
|
-
|
|
11370
|
-
const hasPromptGuardHook = settings.hooks[preToolEvent].some(entry =>
|
|
11371
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-prompt-guard'))
|
|
11372
|
-
);
|
|
11373
|
-
|
|
11374
|
-
const promptGuardFile = path.join(targetDir, 'hooks', 'gsd-prompt-guard.js');
|
|
11375
|
-
if (!hasPromptGuardHook && fs.existsSync(promptGuardFile) && promptGuardCommand) {
|
|
11376
|
-
settings.hooks[preToolEvent].push({
|
|
11377
|
-
matcher: 'Write|Edit',
|
|
11378
|
-
hooks: [
|
|
11379
|
-
{
|
|
11380
|
-
type: 'command',
|
|
11381
|
-
command: promptGuardCommand,
|
|
11382
|
-
timeout: 5
|
|
11383
|
-
}
|
|
11384
|
-
]
|
|
11385
|
-
});
|
|
11386
|
-
console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
|
|
11387
|
-
} else if (!hasPromptGuardHook && !fs.existsSync(promptGuardFile)) {
|
|
11388
|
-
console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`);
|
|
11389
|
-
}
|
|
11390
|
-
|
|
11391
|
-
// Configure PreToolUse hook for read-before-edit guidance (#1628)
|
|
11392
|
-
// Prevents infinite retry loops when non-Claude models attempt to edit
|
|
11393
|
-
// files without reading them first. Advisory-only — does not block.
|
|
11394
|
-
const hasReadGuardHook = settings.hooks[preToolEvent].some(entry =>
|
|
11395
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-read-guard'))
|
|
11396
|
-
);
|
|
11397
|
-
|
|
11398
|
-
const readGuardFile = path.join(targetDir, 'hooks', 'gsd-read-guard.js');
|
|
11399
|
-
if (!hasReadGuardHook && fs.existsSync(readGuardFile) && readGuardCommand) {
|
|
11400
|
-
settings.hooks[preToolEvent].push({
|
|
11401
|
-
matcher: 'Write|Edit',
|
|
11402
|
-
hooks: [
|
|
11403
|
-
{
|
|
11404
|
-
type: 'command',
|
|
11405
|
-
command: readGuardCommand,
|
|
11406
|
-
timeout: 5
|
|
11407
|
-
}
|
|
11408
|
-
]
|
|
11409
|
-
});
|
|
11410
|
-
console.log(` ${green}✓${reset} Configured read-before-edit guard hook`);
|
|
11411
|
-
} else if (!hasReadGuardHook && !fs.existsSync(readGuardFile)) {
|
|
11412
|
-
console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`);
|
|
11413
|
-
}
|
|
11414
|
-
|
|
11415
|
-
// Configure PostToolUse hook for read-time prompt injection scanning (#2201)
|
|
11416
|
-
// Scans content returned by the Read tool for injection patterns, including
|
|
11417
|
-
// summarisation-specific patterns that survive context compression.
|
|
11418
|
-
const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some(entry =>
|
|
11419
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-read-injection-scanner'))
|
|
11420
|
-
);
|
|
11421
|
-
|
|
11422
|
-
const readInjectionScannerFile = path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js');
|
|
11423
|
-
if (!hasReadInjectionScannerHook && fs.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) {
|
|
11424
|
-
settings.hooks[postToolEvent].push({
|
|
11425
|
-
matcher: 'Read',
|
|
11426
|
-
hooks: [
|
|
11427
|
-
{
|
|
11428
|
-
type: 'command',
|
|
11429
|
-
command: readInjectionScannerCommand,
|
|
11430
|
-
timeout: 5
|
|
11431
|
-
}
|
|
11432
|
-
]
|
|
11433
|
-
});
|
|
11434
|
-
console.log(` ${green}✓${reset} Configured read injection scanner hook`);
|
|
11435
|
-
} else if (!hasReadInjectionScannerHook && !fs.existsSync(readInjectionScannerFile)) {
|
|
11436
|
-
console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`);
|
|
11437
|
-
}
|
|
11438
|
-
|
|
11439
|
-
// Community hooks — registered on install but opt-in at runtime.
|
|
11440
|
-
// Each hook checks .planning/config.json for hooks.community: true
|
|
11441
|
-
// and exits silently (no-op) if not enabled. This lets users enable
|
|
11442
|
-
// them per-project by adding: "hooks": { "community": true }
|
|
11443
|
-
|
|
11444
|
-
// Configure workflow guard hook (opt-in via hooks.workflow_guard: true)
|
|
11445
|
-
// Detects file edits outside GSD workflow context and advises using
|
|
11446
|
-
// /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks
|
|
11447
|
-
// unsafe Bash commands that violate worktree-agent isolation.
|
|
11448
|
-
const workflowGuardCommand = isGlobal
|
|
11449
|
-
? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts)
|
|
11450
|
-
: localCmd('gsd-workflow-guard.js');
|
|
11451
|
-
const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit';
|
|
11452
|
-
const workflowGuardHookEntry = settings.hooks[preToolEvent].find(entry =>
|
|
11453
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-workflow-guard'))
|
|
11454
|
-
);
|
|
11455
|
-
const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry);
|
|
11456
|
-
|
|
11457
|
-
const workflowGuardFile = path.join(targetDir, 'hooks', 'gsd-workflow-guard.js');
|
|
11458
|
-
if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) {
|
|
11459
|
-
workflowGuardHookEntry.matcher = workflowGuardMatcher;
|
|
11460
|
-
console.log(` ${green}✓${reset} Updated workflow guard hook matcher`);
|
|
11461
|
-
} else if (!hasWorkflowGuardHook && fs.existsSync(workflowGuardFile) && workflowGuardCommand) {
|
|
11462
|
-
settings.hooks[preToolEvent].push({
|
|
11463
|
-
matcher: workflowGuardMatcher,
|
|
11464
|
-
hooks: [
|
|
11465
|
-
{
|
|
11466
|
-
type: 'command',
|
|
11467
|
-
command: workflowGuardCommand,
|
|
11468
|
-
timeout: 5
|
|
11469
|
-
}
|
|
11470
|
-
]
|
|
11471
|
-
});
|
|
11472
|
-
console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`);
|
|
11473
|
-
} else if (!hasWorkflowGuardHook && !fs.existsSync(workflowGuardFile)) {
|
|
11474
|
-
console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`);
|
|
11475
|
-
}
|
|
11476
|
-
|
|
11477
|
-
// Configure PreToolUse hook for worktree absolute-path safety (#260)
|
|
11478
|
-
// Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve
|
|
11479
|
-
// outside the current worktree root. Prevents executor agents from
|
|
11480
|
-
// accidentally writing to the main checkout when running in isolation="worktree".
|
|
11481
|
-
const worktreePathGuardCommand = isGlobal
|
|
11482
|
-
? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts)
|
|
11483
|
-
: localCmd('gsd-worktree-path-guard.js');
|
|
11484
|
-
const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some(entry =>
|
|
11485
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-worktree-path-guard'))
|
|
11486
|
-
);
|
|
11487
|
-
const worktreePathGuardFile = path.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js');
|
|
11488
|
-
if (!hasWorktreePathGuardHook && fs.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) {
|
|
11489
|
-
settings.hooks[preToolEvent].push({
|
|
11490
|
-
matcher: 'Write|Edit|MultiEdit',
|
|
11491
|
-
hooks: [
|
|
11492
|
-
{
|
|
11493
|
-
type: 'command',
|
|
11494
|
-
command: worktreePathGuardCommand,
|
|
11495
|
-
timeout: 5
|
|
11496
|
-
}
|
|
11497
|
-
]
|
|
11498
|
-
});
|
|
11499
|
-
console.log(` ${green}✓${reset} Configured worktree path guard hook`);
|
|
11500
|
-
} else if (!hasWorktreePathGuardHook && !fs.existsSync(worktreePathGuardFile)) {
|
|
11501
|
-
console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`);
|
|
11502
|
-
}
|
|
11503
|
-
|
|
11504
|
-
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
|
|
11505
|
-
const validateCommitCommand = isGlobal
|
|
11506
|
-
? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
|
|
11507
|
-
: localShellCmd('gsd-validate-commit.sh');
|
|
11508
|
-
const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
|
|
11509
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit'))
|
|
11510
|
-
);
|
|
11511
|
-
// Guard: only register if the .sh file was actually installed. If the npm package
|
|
11512
|
-
// omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook
|
|
11513
|
-
// causes a hook error on every Bash tool invocation.
|
|
11514
|
-
const validateCommitFile = path.join(targetDir, 'hooks', 'gsd-validate-commit.sh');
|
|
11515
|
-
if (!hasValidateCommitHook && fs.existsSync(validateCommitFile) && validateCommitCommand) {
|
|
11516
|
-
settings.hooks[preToolEvent].push({
|
|
11517
|
-
matcher: 'Bash',
|
|
11518
|
-
hooks: [
|
|
11519
|
-
{
|
|
11520
|
-
type: 'command',
|
|
11521
|
-
command: validateCommitCommand,
|
|
11522
|
-
timeout: 5
|
|
11523
|
-
}
|
|
11524
|
-
]
|
|
11525
|
-
});
|
|
11526
|
-
console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
|
|
11527
|
-
} else if (!hasValidateCommitHook && !fs.existsSync(validateCommitFile)) {
|
|
11528
|
-
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`);
|
|
11529
|
-
} else if (!hasValidateCommitHook && !validateCommitCommand) {
|
|
11530
|
-
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`);
|
|
11531
|
-
}
|
|
11532
|
-
|
|
11533
|
-
// Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347).
|
|
11534
|
-
// PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick
|
|
11535
|
-
// on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless
|
|
11536
|
-
// .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true.
|
|
11537
|
-
const graphifyUpdateCommand = isGlobal
|
|
11538
|
-
? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
|
|
11539
|
-
: localShellCmd('gsd-graphify-update.sh');
|
|
11540
|
-
const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry =>
|
|
11541
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-graphify-update'))
|
|
11542
|
-
);
|
|
11543
|
-
const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
|
|
11544
|
-
if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
|
|
11545
|
-
settings.hooks[postToolEvent].push({
|
|
11546
|
-
matcher: 'Bash',
|
|
11547
|
-
hooks: [
|
|
11548
|
-
{
|
|
11549
|
-
type: 'command',
|
|
11550
|
-
command: graphifyUpdateCommand,
|
|
11551
|
-
timeout: 5
|
|
11552
|
-
}
|
|
11553
|
-
]
|
|
11554
|
-
});
|
|
11555
|
-
console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`);
|
|
11556
|
-
} else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) {
|
|
11557
|
-
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`);
|
|
11558
|
-
} else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) {
|
|
11559
|
-
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`);
|
|
11560
|
-
}
|
|
11561
|
-
|
|
11562
|
-
// Configure session state orientation hook (opt-in)
|
|
11563
|
-
const sessionStateCommand = isGlobal
|
|
11564
|
-
? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
|
|
11565
|
-
: localShellCmd('gsd-session-state.sh');
|
|
11566
|
-
const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
|
|
11567
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state'))
|
|
11568
|
-
);
|
|
11569
|
-
const sessionStateFile = path.join(targetDir, 'hooks', 'gsd-session-state.sh');
|
|
11570
|
-
if (!hasSessionStateHook && fs.existsSync(sessionStateFile) && sessionStateCommand) {
|
|
11571
|
-
settings.hooks.SessionStart.push({
|
|
11572
|
-
hooks: [
|
|
11573
|
-
{
|
|
11574
|
-
type: 'command',
|
|
11575
|
-
command: sessionStateCommand
|
|
11576
|
-
}
|
|
11577
|
-
]
|
|
11578
|
-
});
|
|
11579
|
-
console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
|
|
11580
|
-
} else if (!hasSessionStateHook && !fs.existsSync(sessionStateFile)) {
|
|
11581
|
-
console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`);
|
|
11582
|
-
} else if (!hasSessionStateHook && !sessionStateCommand) {
|
|
11583
|
-
console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`);
|
|
11584
|
-
}
|
|
11585
|
-
|
|
11586
|
-
// Configure phase boundary detection hook (opt-in)
|
|
11587
|
-
const phaseBoundaryCommand = isGlobal
|
|
11588
|
-
? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
|
|
11589
|
-
: localShellCmd('gsd-phase-boundary.sh');
|
|
11590
|
-
const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
|
|
11591
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary'))
|
|
11592
|
-
);
|
|
11593
|
-
const phaseBoundaryFile = path.join(targetDir, 'hooks', 'gsd-phase-boundary.sh');
|
|
11594
|
-
if (!hasPhaseBoundaryHook && fs.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) {
|
|
11595
|
-
settings.hooks[postToolEvent].push({
|
|
11596
|
-
matcher: 'Write|Edit',
|
|
11597
|
-
hooks: [
|
|
11598
|
-
{
|
|
11599
|
-
type: 'command',
|
|
11600
|
-
command: phaseBoundaryCommand,
|
|
11601
|
-
timeout: 5
|
|
11602
|
-
}
|
|
11603
|
-
]
|
|
11604
|
-
});
|
|
11605
|
-
console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
|
|
11606
|
-
} else if (!hasPhaseBoundaryHook && !fs.existsSync(phaseBoundaryFile)) {
|
|
11607
|
-
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`);
|
|
11608
|
-
} else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
|
|
11609
|
-
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
|
|
11610
|
-
}
|
|
11611
|
-
|
|
11612
|
-
// ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
|
|
11613
|
-
// Claude Code (since #770) and Qwen Code (since #788) both support these
|
|
11614
|
-
// three lifecycle events. Wire gsd-context-monitor so agents get context-
|
|
11615
|
-
// headroom warnings at subagent completion, model stop, and pre-compaction
|
|
11616
|
-
// (the most critical moment to surface headroom info).
|
|
11617
|
-
//
|
|
11618
|
-
// SubagentStop — subagent lifecycle completion (context headroom tracking)
|
|
11619
|
-
// Stop — model stop / final-response moment (context headroom)
|
|
11620
|
-
// PreCompact — fires before conversation compaction (most critical
|
|
11621
|
-
// moment to surface context headroom warnings)
|
|
11622
|
-
//
|
|
11623
|
-
// Note: UserPromptSubmit is NOT wired here. That event carries the raw
|
|
11624
|
-
// user prompt text, not a tool invocation, so gsd-prompt-guard (which
|
|
11625
|
-
// exits unless tool_name is Write/Edit) would be a silent no-op. A
|
|
11626
|
-
// dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
|
|
11627
|
-
if (isQwen || runtime === 'claude') {
|
|
11628
|
-
const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code';
|
|
11629
|
-
// SubagentStop, Stop, PreCompact — route through the context monitor.
|
|
11630
|
-
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
|
11631
|
-
if (!settings.hooks[event]) {
|
|
11632
|
-
settings.hooks[event] = [];
|
|
11633
|
-
}
|
|
11634
|
-
const alreadyHasContextMonitor = settings.hooks[event].some(entry =>
|
|
11635
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
|
|
11636
|
-
);
|
|
11637
|
-
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
11638
|
-
settings.hooks[event].push({
|
|
11639
|
-
hooks: [
|
|
11640
|
-
{
|
|
11641
|
-
type: 'command',
|
|
11642
|
-
command: contextMonitorCommand,
|
|
11643
|
-
timeout: 10
|
|
11644
|
-
}
|
|
11645
|
-
]
|
|
11646
|
-
});
|
|
11647
|
-
console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`);
|
|
11648
|
-
} else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
|
|
11649
|
-
console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`);
|
|
11650
|
-
}
|
|
11651
|
-
}
|
|
11652
|
-
}
|
|
11653
|
-
// ── end SubagentStop / Stop / PreCompact events ────────────────────────────
|
|
11654
|
-
|
|
11655
|
-
// ── Gemini-only extended hook events (#776) ───────────────────────────────
|
|
11656
|
-
// Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
|
|
11657
|
-
// gsd previously did not register. Three high-value events are added here:
|
|
11658
|
-
//
|
|
11659
|
-
// BeforeAgent — fires after user submits a prompt, before the agent
|
|
11660
|
-
// plans. Wire gsd-context-monitor for context headroom
|
|
11661
|
-
// awareness at prompt time.
|
|
11662
|
-
// AfterAgent — fires once per turn after the model generates its final
|
|
11663
|
-
// response. Wire gsd-context-monitor to track headroom
|
|
11664
|
-
// after each agent turn completes.
|
|
11665
|
-
// BeforeModel — fires before each LLM call (per-turn, not per-session).
|
|
11666
|
-
// Wire gsd-context-monitor for per-turn context injection
|
|
11667
|
-
// — more precise than session-start-only injection.
|
|
11668
|
-
//
|
|
11669
|
-
// All three reuse gsd-context-monitor.js — no new hook files needed.
|
|
11670
|
-
// The `decision:"deny"` retry capability of AfterAgent is intentionally
|
|
11671
|
-
// left to the hook script to implement when triggered (gsd-context-monitor
|
|
11672
|
-
// exits 0 / advisory-only today; an active quality gate is a follow-on).
|
|
11673
|
-
//
|
|
11674
|
-
// Note: BeforeToolSelection is NOT wired. That event does not map to a
|
|
11675
|
-
// gsd hook use case at this time; deferred to a follow-on issue.
|
|
11676
|
-
//
|
|
11677
|
-
// Guard: isGemini is defined at the top of install() (line ~8696).
|
|
11678
|
-
if (isGemini) {
|
|
11679
|
-
for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
|
11680
|
-
if (!Array.isArray(settings.hooks[geminiEvent])) {
|
|
11681
|
-
settings.hooks[geminiEvent] = [];
|
|
11682
|
-
}
|
|
11683
|
-
const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry =>
|
|
11684
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
|
|
11685
|
-
);
|
|
11686
|
-
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
11687
|
-
settings.hooks[geminiEvent].push({
|
|
11688
|
-
hooks: [
|
|
11689
|
-
{
|
|
11690
|
-
type: 'command',
|
|
11691
|
-
command: contextMonitorCommand,
|
|
11692
|
-
timeout: 10
|
|
11693
|
-
}
|
|
11694
|
-
]
|
|
11695
|
-
});
|
|
11696
|
-
console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
|
|
11697
|
-
} else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
|
|
11698
|
-
console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
|
|
11699
|
-
}
|
|
11700
|
-
}
|
|
11701
|
-
}
|
|
11702
|
-
// ── end Gemini-only extended hook events ──────────────────────────────────
|
|
11703
|
-
|
|
11704
|
-
// ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─
|
|
11705
|
-
// Claude Code fires FileChanged when a watched file changes on disk. Wire
|
|
11706
|
-
// gsd-config-reload.js to reload the gsd config context whenever the user
|
|
11707
|
-
// edits .planning/config.json mid-session, eliminating the need to restart.
|
|
11708
|
-
//
|
|
11709
|
-
// The matcher "config.json" watches for changes to any file named config.json
|
|
11710
|
-
// (Claude Code matches by filename, not full path). The hook exits silently
|
|
11711
|
-
// when the changed file is not the gsd config.
|
|
11712
|
-
//
|
|
11713
|
-
// Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
|
|
11714
|
-
// verified; extend in a follow-on if empirically confirmed.
|
|
11715
|
-
if (runtime === 'claude') {
|
|
11716
|
-
if (!settings.hooks.FileChanged) {
|
|
11717
|
-
settings.hooks.FileChanged = [];
|
|
11718
|
-
}
|
|
11719
|
-
const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js');
|
|
11720
|
-
const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry =>
|
|
11721
|
-
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-config-reload'))
|
|
11722
|
-
);
|
|
11723
|
-
if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) {
|
|
11724
|
-
settings.hooks.FileChanged.push({
|
|
11725
|
-
matcher: 'config.json',
|
|
11726
|
-
hooks: [
|
|
11727
|
-
{
|
|
11728
|
-
type: 'command',
|
|
11729
|
-
command: configReloadCommand,
|
|
11730
|
-
timeout: 8
|
|
11731
|
-
}
|
|
11732
|
-
]
|
|
11733
|
-
});
|
|
11734
|
-
console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`);
|
|
11735
|
-
} else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) {
|
|
11736
|
-
console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`);
|
|
11737
|
-
} else if (!alreadyHasConfigReload && !configReloadCommand) {
|
|
11738
|
-
console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`);
|
|
11739
|
-
}
|
|
11740
|
-
}
|
|
11741
|
-
// ── end FileChanged hook ────────────────────────────────────────────────────
|
|
11742
|
-
}
|
|
11176
|
+
// Register all GSD-managed hook entries into settings.hooks.* for runtimes
|
|
11177
|
+
// that use the settings.json hook surface (ADR-857 phase 5f-1b).
|
|
11178
|
+
// settings is mutated in place by applySettingsJsonHooks.
|
|
11179
|
+
applySettingsJsonHooks(settings, {
|
|
11180
|
+
runtime,
|
|
11181
|
+
isGlobal,
|
|
11182
|
+
targetDir,
|
|
11183
|
+
postToolEvent,
|
|
11184
|
+
hookEvents: _hookEventsDialect,
|
|
11185
|
+
extendedHookEvents: plan.extendedHookEvents,
|
|
11186
|
+
hooksSurface: plan.hooksSurface,
|
|
11187
|
+
updateCheckCommand,
|
|
11188
|
+
contextMonitorCommand,
|
|
11189
|
+
promptGuardCommand,
|
|
11190
|
+
readGuardCommand,
|
|
11191
|
+
readInjectionScannerCommand,
|
|
11192
|
+
configReloadCommand,
|
|
11193
|
+
hookOpts,
|
|
11194
|
+
localCmd,
|
|
11195
|
+
localShellCmd,
|
|
11196
|
+
});
|
|
11743
11197
|
|
|
11744
11198
|
// ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
|
|
11745
11199
|
// Detect `hooksConfig.enabled: false` in the already-loaded settings object
|
|
@@ -11854,9 +11308,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
11854
11308
|
const isWindsurf = runtime === 'windsurf';
|
|
11855
11309
|
const isTrae = runtime === 'trae';
|
|
11856
11310
|
const isCline = runtime === 'cline';
|
|
11857
|
-
const
|
|
11311
|
+
const plan = resolveInstallPlan(runtime);
|
|
11858
11312
|
|
|
11859
|
-
if (shouldInstallStatusline &&
|
|
11313
|
+
if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
|
|
11860
11314
|
if (!isGlobal && !forceStatusline) {
|
|
11861
11315
|
// Local installs skip statusLine by default: repo settings.json takes precedence over
|
|
11862
11316
|
// profile-level settings.json in Claude Code, so writing here would silently clobber
|
|
@@ -11882,14 +11336,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
11882
11336
|
// settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
|
|
11883
11337
|
// cline either lack the surface or use a different config schema.
|
|
11884
11338
|
const { shouldInstallBanner, bannerCommand } = bannerOpts;
|
|
11885
|
-
if (shouldInstallBanner && settings &&
|
|
11339
|
+
if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
|
|
11886
11340
|
if (!bannerCommand) {
|
|
11887
11341
|
console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
|
|
11888
11342
|
} else {
|
|
11889
11343
|
if (!settings.hooks) settings.hooks = {};
|
|
11890
11344
|
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
|
11891
11345
|
const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
|
|
11892
|
-
entry && entry.hooks && entry.hooks.some(h => h && h
|
|
11346
|
+
entry && entry.hooks && entry.hooks.some(h => h && referencesHook(h, 'gsd-update-banner'))
|
|
11893
11347
|
);
|
|
11894
11348
|
const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'gsd-update-banner.js') : null;
|
|
11895
11349
|
const bannerInstalled = bannerHookFile ? fs.existsSync(bannerHookFile) : false;
|
|
@@ -11922,17 +11376,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
11922
11376
|
// {type: 'command', command: null} items that the runtime hook schema
|
|
11923
11377
|
// rejects at parse time. validateHookFields filters those out so the file
|
|
11924
11378
|
// we write is always schema-valid.
|
|
11925
|
-
if (
|
|
11379
|
+
if (settingsPath && settings && plan.writesSharedSettings) {
|
|
11926
11380
|
writeSettings(settingsPath, validateHookFields(settings));
|
|
11927
11381
|
}
|
|
11928
11382
|
|
|
11929
11383
|
// Configure OpenCode permissions
|
|
11930
|
-
if (
|
|
11384
|
+
if (plan.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
|
|
11931
11385
|
configureOpencodePermissions(isGlobal, configDir);
|
|
11932
11386
|
}
|
|
11933
11387
|
|
|
11934
11388
|
// Configure Kilo permissions
|
|
11935
|
-
if (
|
|
11389
|
+
if (plan.finishPermissionWriter === 'kilo') {
|
|
11936
11390
|
configureKiloPermissions(isGlobal, configDir);
|
|
11937
11391
|
}
|
|
11938
11392
|
|
|
@@ -11971,6 +11425,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
11971
11425
|
if (runtime === 'cline') program = 'Cline';
|
|
11972
11426
|
if (runtime === 'qwen') program = 'Qwen Code';
|
|
11973
11427
|
if (runtime === 'hermes') program = 'Hermes Agent';
|
|
11428
|
+
if (runtime === 'kimi') program = 'Kimi CLI';
|
|
11974
11429
|
|
|
11975
11430
|
let command = '/gsd-new-project';
|
|
11976
11431
|
if (runtime === 'opencode') command = '/gsd-new-project';
|
|
@@ -11986,6 +11441,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
11986
11441
|
if (runtime === 'cline') command = '/gsd-new-project';
|
|
11987
11442
|
if (runtime === 'qwen') command = '/gsd-new-project';
|
|
11988
11443
|
if (runtime === 'hermes') command = '/gsd-new-project';
|
|
11444
|
+
if (runtime === 'kimi') command = '/skill:gsd-new-project';
|
|
11989
11445
|
|
|
11990
11446
|
// Claude Code global installs use the skills/ format (CC 2.1.88+).
|
|
11991
11447
|
// Restart is required for CC to pick up newly-installed skills, and the
|
|
@@ -12000,6 +11456,16 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
12000
11456
|
return;
|
|
12001
11457
|
}
|
|
12002
11458
|
|
|
11459
|
+
if (runtime === 'kimi') {
|
|
11460
|
+
const agentPath = configDir ? path.join(configDir, 'agents', 'gsd.yaml') : 'agents/gsd.yaml';
|
|
11461
|
+
console.log(`
|
|
11462
|
+
${green}Done!${reset} Start ${program} with ${cyan}kimi --agent-file ${agentPath}${reset}, then run ${cyan}${command}${reset}.
|
|
11463
|
+
|
|
11464
|
+
${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
|
|
11465
|
+
`);
|
|
11466
|
+
return;
|
|
11467
|
+
}
|
|
11468
|
+
|
|
12003
11469
|
console.log(`
|
|
12004
11470
|
${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
|
|
12005
11471
|
|
|
@@ -12076,14 +11542,15 @@ const runtimeMap = {
|
|
|
12076
11542
|
'8': 'cursor',
|
|
12077
11543
|
'9': 'gemini',
|
|
12078
11544
|
'10': 'hermes',
|
|
12079
|
-
'11': '
|
|
12080
|
-
'12': '
|
|
12081
|
-
'13': '
|
|
12082
|
-
'14': '
|
|
12083
|
-
'15': '
|
|
11545
|
+
'11': 'kimi',
|
|
11546
|
+
'12': 'kilo',
|
|
11547
|
+
'13': 'opencode',
|
|
11548
|
+
'14': 'qwen',
|
|
11549
|
+
'15': 'trae',
|
|
11550
|
+
'16': 'windsurf'
|
|
12084
11551
|
};
|
|
12085
|
-
const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
|
|
12086
|
-
const ALL_RUNTIMES_OPTION = '
|
|
11552
|
+
const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kimi', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
|
|
11553
|
+
const ALL_RUNTIMES_OPTION = '17';
|
|
12087
11554
|
|
|
12088
11555
|
/**
|
|
12089
11556
|
* Build the runtime-selection prompt text shown by the interactive installer.
|
|
@@ -12101,12 +11568,13 @@ function buildRuntimePromptText() {
|
|
|
12101
11568
|
${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset}
|
|
12102
11569
|
${cyan}9${reset}) Gemini ${dim}(~/.gemini)${reset}
|
|
12103
11570
|
${cyan}10${reset}) Hermes Agent ${dim}(~/.hermes)${reset}
|
|
12104
|
-
${cyan}11${reset})
|
|
12105
|
-
${cyan}12${reset})
|
|
12106
|
-
${cyan}13${reset})
|
|
12107
|
-
${cyan}14${reset})
|
|
12108
|
-
${cyan}15${reset})
|
|
12109
|
-
${cyan}16${reset})
|
|
11571
|
+
${cyan}11${reset}) Kimi ${dim}(~/.config/agents, then ~/.agents if existing)${reset}
|
|
11572
|
+
${cyan}12${reset}) Kilo ${dim}(~/.config/kilo)${reset}
|
|
11573
|
+
${cyan}13${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
|
|
11574
|
+
${cyan}14${reset}) Qwen Code ${dim}(~/.qwen)${reset}
|
|
11575
|
+
${cyan}15${reset}) Trae ${dim}(~/.trae)${reset}
|
|
11576
|
+
${cyan}16${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
|
|
11577
|
+
${cyan}17${reset}) All
|
|
12110
11578
|
|
|
12111
11579
|
${dim}Select multiple: 1,2,6 or 1 2 6${reset}
|
|
12112
11580
|
`;
|
|
@@ -12117,7 +11585,7 @@ function buildRuntimePromptText() {
|
|
|
12117
11585
|
* Pure function — exported so tests can verify split/dedupe/fallback behavior.
|
|
12118
11586
|
* - Accepts comma- and/or whitespace-separated choices
|
|
12119
11587
|
* - Deduplicates while preserving order
|
|
12120
|
-
* - Maps option
|
|
11588
|
+
* - Maps option 17 ("All") to every runtime
|
|
12121
11589
|
* - Falls back to ['claude'] when nothing valid is selected
|
|
12122
11590
|
*/
|
|
12123
11591
|
function parseRuntimeInput(answer) {
|
|
@@ -12457,9 +11925,11 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
|
|
|
12457
11925
|
'.codex',
|
|
12458
11926
|
'.copilot',
|
|
12459
11927
|
'.github', // copilot local form
|
|
12460
|
-
'.
|
|
11928
|
+
'.agents', // antigravity local form (canonical, #791)
|
|
11929
|
+
'.agent', // antigravity local form (legacy, backward-compat)
|
|
12461
11930
|
'.cursor',
|
|
12462
|
-
'.
|
|
11931
|
+
'.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
|
|
11932
|
+
'.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
|
|
12463
11933
|
'.codeium/windsurf',
|
|
12464
11934
|
'.augment',
|
|
12465
11935
|
'.trae',
|
|
@@ -12569,6 +12039,8 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
|
|
12569
12039
|
try {
|
|
12570
12040
|
const printSummaries = () => {
|
|
12571
12041
|
for (const result of results) {
|
|
12042
|
+
if (result && result.skipped) continue;
|
|
12043
|
+
if (!result) continue;
|
|
12572
12044
|
const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
|
|
12573
12045
|
finishInstall(
|
|
12574
12046
|
result.settingsPath,
|
|
@@ -12682,6 +12154,10 @@ module.exports = {
|
|
|
12682
12154
|
uninstall,
|
|
12683
12155
|
convertSlashCommandsToCodexSkillMentions,
|
|
12684
12156
|
convertClaudeCommandToCodexSkill,
|
|
12157
|
+
convertClaudeCommandToKimiSkill,
|
|
12158
|
+
convertKimiToolName,
|
|
12159
|
+
mapClaudeToolsToKimiTools,
|
|
12160
|
+
buildKimiAgentArtifacts,
|
|
12685
12161
|
convertClaudeToOpencodeFrontmatter,
|
|
12686
12162
|
convertClaudeToKiloFrontmatter,
|
|
12687
12163
|
convertClaudeCommandToOpencodeSkill,
|
|
@@ -12695,6 +12171,7 @@ module.exports = {
|
|
|
12695
12171
|
GSD_CODEX_MARKER,
|
|
12696
12172
|
CODEX_AGENT_SANDBOX,
|
|
12697
12173
|
getDirName,
|
|
12174
|
+
getGlobalDir,
|
|
12698
12175
|
getConfigDirFromHome,
|
|
12699
12176
|
resolveKiloConfigPath,
|
|
12700
12177
|
configureKiloPermissions,
|
|
@@ -12761,6 +12238,7 @@ module.exports = {
|
|
|
12761
12238
|
maybeSuggestPathExport,
|
|
12762
12239
|
runtimeMap,
|
|
12763
12240
|
allRuntimes,
|
|
12241
|
+
selectRuntimesFromArgs,
|
|
12764
12242
|
GSD_UNINSTALL_HOOKS,
|
|
12765
12243
|
parseRuntimeInput,
|
|
12766
12244
|
buildRuntimePromptText,
|
|
@@ -12770,6 +12248,8 @@ module.exports = {
|
|
|
12770
12248
|
buildHookCommand,
|
|
12771
12249
|
normalizeNodePath,
|
|
12772
12250
|
resolveNodeRunner,
|
|
12251
|
+
referencesHook,
|
|
12252
|
+
applySettingsJsonHooks,
|
|
12773
12253
|
rewriteLegacyManagedNodeHookCommands,
|
|
12774
12254
|
buildCodexHookBlock,
|
|
12775
12255
|
rewriteLegacyCodexHookBlock,
|
|
@@ -12811,12 +12291,11 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
|
|
|
12811
12291
|
console.error('Usage: node install.js --skills-root <runtime>');
|
|
12812
12292
|
process.exit(1);
|
|
12813
12293
|
}
|
|
12814
|
-
const
|
|
12815
|
-
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
: path.join(globalDir, 'skills');
|
|
12294
|
+
const skillsRoot = getGlobalSkillsBase(runtimeArg);
|
|
12295
|
+
if (skillsRoot === null) {
|
|
12296
|
+
console.error(`${runtimeArg} does not use a skills directory`);
|
|
12297
|
+
process.exit(1);
|
|
12298
|
+
}
|
|
12820
12299
|
console.log(skillsRoot);
|
|
12821
12300
|
} else if (hasGlobal && hasLocal) {
|
|
12822
12301
|
console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
|