@imdeadpool/guardex 7.0.41 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +94 -13
  2. package/package.json +3 -1
  3. package/skills/gitguardex/SKILL.md +13 -0
  4. package/skills/guardex-merge-skills-to-dev/SKILL.md +59 -0
  5. package/skills/gx-act/SKILL.md +82 -0
  6. package/src/agents/cleanup-sessions.js +126 -0
  7. package/src/agents/finish.js +172 -0
  8. package/src/agents/inspect.js +202 -0
  9. package/src/agents/launch.js +249 -0
  10. package/src/agents/registry.js +133 -0
  11. package/src/agents/selection-panel.js +571 -0
  12. package/src/agents/sessions.js +151 -0
  13. package/src/agents/start.js +591 -0
  14. package/src/agents/status.js +146 -0
  15. package/src/agents/terminal.js +152 -0
  16. package/src/budget/index.js +344 -0
  17. package/src/ci-init/index.js +265 -0
  18. package/src/cli/args.js +357 -3
  19. package/src/cli/commands/agents.js +364 -0
  20. package/src/cli/commands/bootstrap.js +92 -0
  21. package/src/cli/commands/branch.js +127 -0
  22. package/src/cli/commands/claude.js +674 -0
  23. package/src/cli/commands/doctor.js +268 -0
  24. package/src/cli/commands/finish.js +26 -0
  25. package/src/cli/commands/mcp.js +122 -0
  26. package/src/cli/commands/misc.js +304 -0
  27. package/src/cli/commands/pr.js +439 -0
  28. package/src/cli/commands/prompt.js +92 -0
  29. package/src/cli/commands/release.js +305 -0
  30. package/src/cli/commands/report.js +244 -0
  31. package/src/cli/commands/review.js +32 -0
  32. package/src/cli/commands/setup.js +242 -0
  33. package/src/cli/commands/status.js +338 -0
  34. package/src/cli/commands/watch.js +234 -0
  35. package/src/cli/main.js +85 -3613
  36. package/src/cli/shared/repo-env.js +161 -0
  37. package/src/cli/shared/sandbox.js +417 -0
  38. package/src/cli/shared/scaffolding.js +535 -0
  39. package/src/cli/shared/toolchain-shims.js +420 -0
  40. package/src/cockpit/action-runner.js +3 -0
  41. package/src/cockpit/actions.js +80 -0
  42. package/src/cockpit/control.js +1121 -0
  43. package/src/cockpit/index.js +426 -0
  44. package/src/cockpit/kitty-layout.js +549 -0
  45. package/src/cockpit/kitty-tree.js +144 -0
  46. package/src/cockpit/logs-reader.js +182 -0
  47. package/src/cockpit/menu.js +204 -0
  48. package/src/cockpit/pane-actions.js +597 -0
  49. package/src/cockpit/pane-menu.js +387 -0
  50. package/src/cockpit/projects-finder.js +178 -0
  51. package/src/cockpit/render.js +215 -0
  52. package/src/cockpit/settings-render.js +128 -0
  53. package/src/cockpit/settings.js +124 -0
  54. package/src/cockpit/shortcuts.js +24 -0
  55. package/src/cockpit/sidebar.js +311 -0
  56. package/src/cockpit/state.js +72 -0
  57. package/src/cockpit/theme.js +128 -0
  58. package/src/cockpit/welcome.js +266 -0
  59. package/src/context.js +304 -43
  60. package/src/core/runtime.js +6 -1
  61. package/src/doctor/index.js +45 -15
  62. package/src/finish/index.js +186 -7
  63. package/src/finish/preflight.js +177 -0
  64. package/src/finish/review-gate.js +182 -0
  65. package/src/git/index.js +511 -4
  66. package/src/hooks/index.js +0 -64
  67. package/src/kitty/command.js +101 -0
  68. package/src/kitty/runtime.js +250 -0
  69. package/src/mcp/collect.js +370 -0
  70. package/src/mcp/server.js +157 -0
  71. package/src/output/index.js +68 -2
  72. package/src/pr-review.js +264 -0
  73. package/src/pr.js +381 -0
  74. package/src/sandbox/index.js +13 -2
  75. package/src/scaffold/agent-worktree-prep.js +213 -0
  76. package/src/scaffold/index.js +127 -10
  77. package/src/speckit/index.js +226 -0
  78. package/src/submodule/index.js +288 -0
  79. package/src/terminal/index.js +45 -0
  80. package/src/terminal/kitty.js +622 -0
  81. package/src/terminal/tmux.js +125 -0
  82. package/src/tmux/command.js +27 -0
  83. package/src/tmux/session.js +89 -0
  84. package/src/toolchain/index.js +20 -0
  85. package/templates/AGENTS.monorepo-apps.md +26 -0
  86. package/templates/AGENTS.multiagent-safety.md +63 -323
  87. package/templates/AGENTS.multiagent-safety.min.md +11 -0
  88. package/templates/codex/skills/gitguardex/SKILL.md +2 -0
  89. package/templates/codex/skills/gx-act/SKILL.md +82 -0
  90. package/templates/githooks/pre-commit +44 -20
  91. package/templates/github/workflows/README.md +87 -0
  92. package/templates/github/workflows/ci-full.yml +55 -0
  93. package/templates/github/workflows/ci.yml +56 -0
  94. package/templates/github/workflows/cr.yml +20 -1
  95. package/templates/scripts/agent-branch-finish.sh +519 -23
  96. package/templates/scripts/agent-branch-merge.sh +4 -1
  97. package/templates/scripts/agent-branch-start.sh +176 -24
  98. package/templates/scripts/agent-preflight.sh +115 -0
  99. package/templates/scripts/agent-worktree-prune.sh +96 -5
  100. package/templates/scripts/codex-agent.sh +41 -97
  101. package/templates/scripts/openspec/init-plan-workspace.sh +43 -0
  102. package/templates/scripts/review-bot-watch.sh +31 -2
  103. package/templates/scripts/agent-session-state.js +0 -171
  104. package/templates/scripts/install-vscode-active-agents-extension.js +0 -135
  105. package/templates/vscode/guardex-active-agents/README.md +0 -34
  106. package/templates/vscode/guardex-active-agents/extension.js +0 -3782
  107. package/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +0 -54
  108. package/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg +0 -5
  109. package/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg +0 -7
  110. package/templates/vscode/guardex-active-agents/fileicons/icons/config.svg +0 -4
  111. package/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg +0 -4
  112. package/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +0 -5
  113. package/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg +0 -4
  114. package/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg +0 -5
  115. package/templates/vscode/guardex-active-agents/icon.png +0 -0
  116. package/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg +0 -14
  117. package/templates/vscode/guardex-active-agents/package.json +0 -169
  118. package/templates/vscode/guardex-active-agents/session-schema.js +0 -1348
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ // Prepares a freshly-created agent worktree for monorepos that have `apps/*`
4
+ // packages. Two jobs:
5
+ //
6
+ // 1. Symlink the root's `apps/<pkg>/.env` (and friends) into the worktree
7
+ // so backend / storefront / etc. can boot with the same secrets without
8
+ // asking the user to copy gitignored env files manually.
9
+ //
10
+ // 2. Pick a free port per app and write it into the worktree's
11
+ // `apps/<pkg>/.env.local` (which both Vite and Medusa's loadEnv read with
12
+ // higher precedence than `.env`). This stops agent dev servers from
13
+ // colliding with whatever's running in the root worktree on the default
14
+ // port.
15
+ //
16
+ // Both jobs are best-effort: if `apps/` doesn't exist, or there are no env
17
+ // files / no package.json in a subfolder, we silently skip — non-monorepo
18
+ // repos see no change.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ const ENV_FILE_CANDIDATES = [
25
+ '.env',
26
+ '.env.local',
27
+ '.env.development',
28
+ '.env.development.local',
29
+ '.env.production',
30
+ '.env.production.local',
31
+ ];
32
+
33
+ // Port pool by detected app role. Storefronts get the Vite/Next range,
34
+ // backends get the Medusa range, everything else gets a generic mid-range.
35
+ const PORT_POOLS = {
36
+ storefront: 5174,
37
+ backend: 9101,
38
+ default: 8100,
39
+ };
40
+
41
+ function detectAppPackages(repoRoot) {
42
+ const appsRoot = path.join(repoRoot, 'apps');
43
+ let stat;
44
+ try {
45
+ stat = fs.statSync(appsRoot);
46
+ } catch {
47
+ return [];
48
+ }
49
+ if (!stat.isDirectory()) return [];
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(appsRoot, { withFileTypes: true });
53
+ } catch {
54
+ return [];
55
+ }
56
+ return entries
57
+ .filter((e) => e.isDirectory())
58
+ .map((e) => e.name)
59
+ .filter((name) => fs.existsSync(path.join(appsRoot, name, 'package.json')));
60
+ }
61
+
62
+ function inferAppRole(appName) {
63
+ const n = appName.toLowerCase();
64
+ if (n.includes('storefront') || n.includes('frontend') || n.includes('web')) {
65
+ return 'storefront';
66
+ }
67
+ if (n.includes('backend') || n.includes('api') || n.includes('server')) {
68
+ return 'backend';
69
+ }
70
+ return 'default';
71
+ }
72
+
73
+ function isPortFree(port) {
74
+ // Use `lsof` if available — it's on macOS and most Linux distros. Fall
75
+ // back to assuming free when lsof isn't installed (e.g. minimal Alpine
76
+ // CI image); the dev server will fail loudly if it isn't.
77
+ const probe = spawnSync('lsof', ['-iTCP:' + port, '-sTCP:LISTEN', '-t'], {
78
+ stdio: ['ignore', 'pipe', 'pipe'],
79
+ timeout: 2000,
80
+ });
81
+ if (probe.error) return true;
82
+ const out = (probe.stdout && probe.stdout.toString().trim()) || '';
83
+ return out === '';
84
+ }
85
+
86
+ function pickFreePort(start) {
87
+ for (let p = start; p < start + 200; p++) {
88
+ if (isPortFree(p)) return p;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function symlinkAppEnvFiles(repoRoot, worktreePath, appName) {
94
+ const operations = [];
95
+ const rootAppDir = path.join(repoRoot, 'apps', appName);
96
+ const wtAppDir = path.join(worktreePath, 'apps', appName);
97
+ if (!fs.existsSync(wtAppDir)) {
98
+ return operations;
99
+ }
100
+ for (const candidate of ENV_FILE_CANDIDATES) {
101
+ const rootEnv = path.join(rootAppDir, candidate);
102
+ const wtEnv = path.join(wtAppDir, candidate);
103
+ if (!fs.existsSync(rootEnv)) continue;
104
+ // Don't overwrite an existing file/symlink in the worktree.
105
+ let alreadyExists = false;
106
+ try {
107
+ fs.lstatSync(wtEnv);
108
+ alreadyExists = true;
109
+ } catch (err) {
110
+ if (err.code !== 'ENOENT') throw err;
111
+ }
112
+ if (alreadyExists) {
113
+ operations.push({
114
+ status: 'unchanged',
115
+ file: `apps/${appName}/${candidate}`,
116
+ note: 'already present in worktree',
117
+ });
118
+ continue;
119
+ }
120
+ try {
121
+ fs.symlinkSync(rootEnv, wtEnv);
122
+ operations.push({
123
+ status: 'linked',
124
+ file: `apps/${appName}/${candidate}`,
125
+ note: `→ ${path.relative(worktreePath, rootEnv)}`,
126
+ });
127
+ } catch (err) {
128
+ operations.push({
129
+ status: 'failed',
130
+ file: `apps/${appName}/${candidate}`,
131
+ note: `symlink failed: ${err.message}`,
132
+ });
133
+ }
134
+ }
135
+ return operations;
136
+ }
137
+
138
+ function assignAgentPort(repoRoot, worktreePath, appName, takenPorts) {
139
+ const wtAppDir = path.join(worktreePath, 'apps', appName);
140
+ if (!fs.existsSync(wtAppDir)) {
141
+ return { status: 'skipped', file: `apps/${appName}`, note: 'no app dir in worktree' };
142
+ }
143
+ const role = inferAppRole(appName);
144
+ const base = PORT_POOLS[role] || PORT_POOLS.default;
145
+ let port = pickFreePort(base);
146
+ // Bump past anything we've already assigned this run.
147
+ while (port !== null && takenPorts.has(port)) {
148
+ port = pickFreePort(port + 1);
149
+ }
150
+ if (port === null) {
151
+ return {
152
+ status: 'failed',
153
+ file: `apps/${appName}/.env.local`,
154
+ note: 'no free port found in pool',
155
+ };
156
+ }
157
+ takenPorts.add(port);
158
+
159
+ const envLocalPath = path.join(wtAppDir, '.env.local');
160
+ let existing = '';
161
+ try {
162
+ existing = fs.readFileSync(envLocalPath, 'utf8');
163
+ } catch (err) {
164
+ if (err.code !== 'ENOENT') throw err;
165
+ }
166
+
167
+ // If a .env.local already exists, replace the PORT= line if present,
168
+ // otherwise append. Keep everything else the user might have added.
169
+ const portLine = `PORT=${port}`;
170
+ let next;
171
+ if (/^PORT=/m.test(existing)) {
172
+ next = existing.replace(/^PORT=.*$/m, portLine);
173
+ } else {
174
+ const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
175
+ next = `${existing}${sep}${portLine}\n`;
176
+ // Header on fresh files so the user knows what wrote this.
177
+ if (existing.length === 0) {
178
+ next = `# Written by gitguardex on worktree creation — agent dev server port.\n${portLine}\n`;
179
+ }
180
+ }
181
+ fs.writeFileSync(envLocalPath, next, 'utf8');
182
+ return {
183
+ status: 'wrote',
184
+ file: `apps/${appName}/.env.local`,
185
+ note: `PORT=${port} (${role} pool)`,
186
+ };
187
+ }
188
+
189
+ function prepareAgentWorktree(repoRoot, worktreePath) {
190
+ if (!repoRoot || !worktreePath) return [];
191
+ if (repoRoot === worktreePath) return [];
192
+ if (!fs.existsSync(worktreePath)) return [];
193
+ const apps = detectAppPackages(repoRoot);
194
+ if (apps.length === 0) return [];
195
+
196
+ const operations = [];
197
+ const takenPorts = new Set();
198
+ for (const appName of apps) {
199
+ operations.push(...symlinkAppEnvFiles(repoRoot, worktreePath, appName));
200
+ operations.push(assignAgentPort(repoRoot, worktreePath, appName, takenPorts));
201
+ }
202
+ return operations;
203
+ }
204
+
205
+ module.exports = {
206
+ detectAppPackages,
207
+ inferAppRole,
208
+ isPortFree,
209
+ pickFreePort,
210
+ symlinkAppEnvFiles,
211
+ assignAgentPort,
212
+ prepareAgentWorktree,
213
+ };
@@ -14,6 +14,8 @@ const {
14
14
  USER_LEVEL_SKILL_ASSETS,
15
15
  AGENTS_MARKER_START,
16
16
  AGENTS_MARKER_END,
17
+ MONOREPO_MARKER_START,
18
+ MONOREPO_MARKER_END,
17
19
  GITIGNORE_MARKER_START,
18
20
  GITIGNORE_MARKER_END,
19
21
  SHARED_VSCODE_SETTINGS_RELATIVE,
@@ -500,31 +502,54 @@ function removeLegacyManagedRepoFile(repoRoot, relativePath, options = {}) {
500
502
  return { status: dryRun ? 'would-remove' : 'removed', file: relativePath };
501
503
  }
502
504
 
503
- function ensureAgentsSnippet(repoRoot, dryRun) {
505
+ // A managed block longer than this many non-blank lines is treated as the full
506
+ // contract. The minimal block is ~8 lines and the full contract is ~171, so any
507
+ // threshold between the two is safe; 40 leaves a wide margin on both sides.
508
+ const FULL_BLOCK_LINE_THRESHOLD = 40;
509
+
510
+ function countNonBlankLines(text) {
511
+ return text.split('\n').filter((line) => line.trim().length > 0).length;
512
+ }
513
+
514
+ // Default install ships the minimal block; the full 171-line contract is opt-in
515
+ // via `options.contract` (--contract / --full). Once a repo has the full block,
516
+ // it is never silently downgraded: an existing managed block over the line
517
+ // threshold keeps refreshing from the full template even without the flag.
518
+ function ensureAgentsSnippet(repoRoot, dryRun, options = {}) {
504
519
  const agentsPath = path.join(repoRoot, 'AGENTS.md');
505
- const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'), 'utf8').trimEnd();
506
520
  const managedRegex = new RegExp(
507
521
  `${AGENTS_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${AGENTS_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
508
522
  'm',
509
523
  );
510
524
 
511
- if (!fs.existsSync(agentsPath)) {
525
+ const existing = fs.existsSync(agentsPath) ? fs.readFileSync(agentsPath, 'utf8') : null;
526
+ const existingBlock = existing ? existing.match(managedRegex) : null;
527
+ const existingIsFull = Boolean(existingBlock)
528
+ && countNonBlankLines(existingBlock[0]) > FULL_BLOCK_LINE_THRESHOLD;
529
+ const wantFull = Boolean(options.contract) || existingIsFull;
530
+
531
+ const templateFile = wantFull
532
+ ? 'AGENTS.multiagent-safety.md'
533
+ : 'AGENTS.multiagent-safety.min.md';
534
+ const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, templateFile), 'utf8').trimEnd();
535
+ const variant = wantFull ? 'full contract block' : 'minimal block';
536
+
537
+ if (existing == null) {
512
538
  if (!dryRun) {
513
539
  fs.writeFileSync(agentsPath, `# AGENTS\n\n${snippet}\n`, 'utf8');
514
540
  }
515
- return { status: 'created', file: 'AGENTS.md' };
541
+ return { status: 'created', file: 'AGENTS.md', note: variant };
516
542
  }
517
543
 
518
- const existing = fs.readFileSync(agentsPath, 'utf8');
519
- if (managedRegex.test(existing)) {
520
- const next = existing.replace(managedRegex, snippet);
544
+ if (existingBlock) {
545
+ const next = existing.replace(managedRegex, () => snippet);
521
546
  if (next === existing) {
522
- return { status: 'unchanged', file: 'AGENTS.md' };
547
+ return { status: 'unchanged', file: 'AGENTS.md', note: variant };
523
548
  }
524
549
  if (!dryRun) {
525
550
  fs.writeFileSync(agentsPath, next, 'utf8');
526
551
  }
527
- return { status: 'updated', file: 'AGENTS.md', note: 'refreshed gitguardex-managed block' };
552
+ return { status: 'updated', file: 'AGENTS.md', note: `refreshed gitguardex-managed block (${variant})` };
528
553
  }
529
554
 
530
555
  if (existing.includes(AGENTS_MARKER_START)) {
@@ -536,7 +561,96 @@ function ensureAgentsSnippet(repoRoot, dryRun) {
536
561
  fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8');
537
562
  }
538
563
 
539
- return { status: 'updated', file: 'AGENTS.md' };
564
+ return { status: 'updated', file: 'AGENTS.md', note: variant };
565
+ }
566
+
567
+ function ensureClaudeAgentsLink(repoRoot, dryRun) {
568
+ const claudePath = path.join(repoRoot, 'CLAUDE.md');
569
+ try {
570
+ fs.lstatSync(claudePath);
571
+ return { status: 'unchanged', file: 'CLAUDE.md', note: 'existing path preserved' };
572
+ } catch (error) {
573
+ if (error.code !== 'ENOENT') {
574
+ throw error;
575
+ }
576
+ }
577
+
578
+ if (!dryRun) {
579
+ fs.symlinkSync('AGENTS.md', claudePath);
580
+ }
581
+
582
+ return { status: dryRun ? 'would-create' : 'created', file: 'CLAUDE.md', note: 'symlink to AGENTS.md' };
583
+ }
584
+
585
+ function detectMonorepoApps(repoRoot) {
586
+ const appsDir = path.join(repoRoot, 'apps');
587
+ let stat;
588
+ try {
589
+ stat = fs.statSync(appsDir);
590
+ } catch {
591
+ return false;
592
+ }
593
+ if (!stat.isDirectory()) return false;
594
+ let entries;
595
+ try {
596
+ entries = fs.readdirSync(appsDir, { withFileTypes: true });
597
+ } catch {
598
+ return false;
599
+ }
600
+ return entries.some(
601
+ (entry) =>
602
+ entry.isDirectory() &&
603
+ fs.existsSync(path.join(appsDir, entry.name, 'package.json')),
604
+ );
605
+ }
606
+
607
+ function ensureMonorepoAppsSnippet(repoRoot, dryRun) {
608
+ const agentsPath = path.join(repoRoot, 'AGENTS.md');
609
+ if (!detectMonorepoApps(repoRoot)) {
610
+ return {
611
+ status: 'skipped',
612
+ file: 'AGENTS.md',
613
+ note: 'no apps/<pkg>/package.json detected — monorepo block not needed',
614
+ };
615
+ }
616
+ const snippet = fs
617
+ .readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.monorepo-apps.md'), 'utf8')
618
+ .trimEnd();
619
+ const managedRegex = new RegExp(
620
+ `${MONOREPO_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${MONOREPO_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
621
+ 'm',
622
+ );
623
+
624
+ // Ensure AGENTS.md exists first (created by ensureAgentsSnippet upstream).
625
+ if (!fs.existsSync(agentsPath)) {
626
+ if (!dryRun) {
627
+ fs.writeFileSync(agentsPath, `# AGENTS\n\n${snippet}\n`, 'utf8');
628
+ }
629
+ return { status: 'created', file: 'AGENTS.md', note: 'monorepo-apps block' };
630
+ }
631
+
632
+ const existing = fs.readFileSync(agentsPath, 'utf8');
633
+ if (managedRegex.test(existing)) {
634
+ const next = existing.replace(managedRegex, snippet);
635
+ if (next === existing) {
636
+ return { status: 'unchanged', file: 'AGENTS.md', note: 'monorepo-apps block' };
637
+ }
638
+ if (!dryRun) {
639
+ fs.writeFileSync(agentsPath, next, 'utf8');
640
+ }
641
+ return {
642
+ status: 'updated',
643
+ file: 'AGENTS.md',
644
+ note: 'refreshed monorepo-apps block',
645
+ };
646
+ }
647
+
648
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
649
+ if (!dryRun) {
650
+ fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8');
651
+ }
652
+
653
+ return { status: 'updated', file: 'AGENTS.md', note: 'appended monorepo-apps block' };
540
654
  }
541
655
 
542
656
  function ensureManagedGitignore(repoRoot, dryRun) {
@@ -763,6 +877,9 @@ module.exports = {
763
877
  installUserLevelAsset,
764
878
  removeLegacyManagedRepoFile,
765
879
  ensureAgentsSnippet,
880
+ ensureClaudeAgentsLink,
881
+ ensureMonorepoAppsSnippet,
882
+ detectMonorepoApps,
766
883
  ensureManagedGitignore,
767
884
  parseJsonObjectLikeFile,
768
885
  buildRepoVscodeSettings,
@@ -0,0 +1,226 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const cp = require('node:child_process');
6
+
7
+ const { TOOL_NAME, SHORT_TOOL_NAME } = require('../context');
8
+
9
+ const SPECIFY_BIN = 'specify';
10
+ const SPECKIT_INSTALL_HINT =
11
+ 'uv tool install specify-cli --from git+https://github.com/github/spec-kit.git';
12
+
13
+ function whichSpecify() {
14
+ const result = cp.spawnSync(process.platform === 'win32' ? 'where' : 'which', [SPECIFY_BIN], {
15
+ encoding: 'utf8',
16
+ });
17
+ if (result.status === 0 && result.stdout && result.stdout.trim()) {
18
+ return result.stdout.trim().split(/\r?\n/)[0];
19
+ }
20
+ return null;
21
+ }
22
+
23
+ function specifyVersion(specifyPath) {
24
+ const result = cp.spawnSync(specifyPath, ['--version'], { encoding: 'utf8' });
25
+ if (result.status === 0 && result.stdout) {
26
+ return result.stdout.trim();
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function isGitRepo(target) {
32
+ try {
33
+ const result = cp.spawnSync('git', ['-C', target, 'rev-parse', '--git-dir'], {
34
+ encoding: 'utf8',
35
+ });
36
+ return result.status === 0;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function listSpecKitOpenSpecScaffolds(target) {
43
+ const planRoot = path.join(target, 'openspec', 'plan');
44
+ const changesRoot = path.join(target, 'openspec', 'changes');
45
+ const result = { planDirs: [], specsDirs: [] };
46
+ if (fs.existsSync(planRoot) && fs.statSync(planRoot).isDirectory()) {
47
+ for (const entry of fs.readdirSync(planRoot)) {
48
+ if (/^agent-.*-masterplan-setup-spec-kit-/i.test(entry)) {
49
+ result.planDirs.push(path.join(planRoot, entry));
50
+ }
51
+ }
52
+ }
53
+ if (fs.existsSync(changesRoot) && fs.statSync(changesRoot).isDirectory()) {
54
+ for (const entry of fs.readdirSync(changesRoot)) {
55
+ if (/^agent-.*-setup-spec-kit-/i.test(entry)) {
56
+ const specsDir = path.join(changesRoot, entry, 'specs');
57
+ if (fs.existsSync(specsDir)) result.specsDirs.push(specsDir);
58
+ }
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ function pruneSpecKitScaffolds(target, { dryRun, logger }) {
65
+ const found = listSpecKitOpenSpecScaffolds(target);
66
+ const removed = [];
67
+ for (const dir of [...found.planDirs, ...found.specsDirs]) {
68
+ if (dryRun) {
69
+ logger(`[${TOOL_NAME}] dry-run: would prune ${path.relative(target, dir)}`);
70
+ } else {
71
+ fs.rmSync(dir, { recursive: true, force: true });
72
+ logger(`[${TOOL_NAME}] pruned ${path.relative(target, dir)}`);
73
+ }
74
+ removed.push(dir);
75
+ }
76
+ return removed;
77
+ }
78
+
79
+ function runSpecifyInit(target, { dryRun, logger }) {
80
+ const args = ['init', '--here', '--ai', 'claude', '--force', '--ignore-agent-tools'];
81
+ if (dryRun) {
82
+ logger(`[${TOOL_NAME}] dry-run: would run \`${SPECIFY_BIN} ${args.join(' ')}\` in ${target}`);
83
+ return { status: 'dry-run' };
84
+ }
85
+ const result = cp.spawnSync(SPECIFY_BIN, args, {
86
+ cwd: target,
87
+ stdio: 'inherit',
88
+ });
89
+ if (result.status !== 0) {
90
+ throw new Error(`${SPECIFY_BIN} init exited with status ${result.status}`);
91
+ }
92
+ return { status: 'ok' };
93
+ }
94
+
95
+ function isSpecKitAlreadyInstalled(target) {
96
+ return fs.existsSync(path.join(target, '.specify', 'integration.json'));
97
+ }
98
+
99
+ function installSpeckit({
100
+ target = process.cwd(),
101
+ dryRun = false,
102
+ prune = true,
103
+ force = false,
104
+ silent = false,
105
+ logger = console.log,
106
+ }) {
107
+ const resolved = path.resolve(target);
108
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
109
+ if (silent) {
110
+ logger(`[${TOOL_NAME}] ⚠️ speckit: target ${resolved} does not exist; skipping.`);
111
+ return { status: 'skipped', reason: 'no-target' };
112
+ }
113
+ throw new Error(`Target directory does not exist: ${resolved}`);
114
+ }
115
+ if (!isGitRepo(resolved)) {
116
+ logger(
117
+ `[${TOOL_NAME}] ⚠️ ${resolved} is not a git repo. Spec Kit will scaffold without git extension wiring.`,
118
+ );
119
+ }
120
+ if (!force && isSpecKitAlreadyInstalled(resolved)) {
121
+ logger(`[${TOOL_NAME}] ✅ Spec Kit already installed at ${resolved}/.specify (use --speckit-force to reinstall).`);
122
+ return { status: 'already-installed', target: resolved };
123
+ }
124
+ const specifyPath = whichSpecify();
125
+ if (!specifyPath) {
126
+ if (silent) {
127
+ logger(
128
+ `[${TOOL_NAME}] ⚠️ speckit: \`${SPECIFY_BIN}\` not on PATH; skipping speckit install. ` +
129
+ `Install with: ${SPECKIT_INSTALL_HINT}`,
130
+ );
131
+ return { status: 'skipped', reason: 'specify-missing' };
132
+ }
133
+ throw new Error(
134
+ `${SPECIFY_BIN} CLI not found on PATH. Install with:\n ${SPECKIT_INSTALL_HINT}`,
135
+ );
136
+ }
137
+ const version = specifyVersion(specifyPath);
138
+ logger(`[${TOOL_NAME}] specify-cli: ${specifyPath}${version ? ` (${version})` : ''}`);
139
+ logger(`[${TOOL_NAME}] Running \`${SPECIFY_BIN} init --here --ai claude --force\` in ${resolved}`);
140
+
141
+ const initResult = runSpecifyInit(resolved, { dryRun, logger });
142
+
143
+ let pruned = [];
144
+ if (prune && (initResult.status === 'ok' || dryRun)) {
145
+ pruned = pruneSpecKitScaffolds(resolved, { dryRun, logger });
146
+ }
147
+
148
+ logger('');
149
+ logger(`[${TOOL_NAME}] ✅ Spec Kit installed. Next:`);
150
+ logger(` - Start a fresh Claude session at ${resolved}`);
151
+ logger(` - Use slash skills: /speckit-constitution, /speckit-specify, /speckit-plan, /speckit-tasks, /speckit-implement`);
152
+ logger(` - Agent worktree flow is unchanged — run \`${SHORT_TOOL_NAME} pivot "<task>" "claude"\` to start work.`);
153
+
154
+ return { status: 'installed', specifyPath, version, dryRun, prunedScaffolds: pruned, target: resolved };
155
+ }
156
+
157
+ function printSpeckitHelp() {
158
+ const lines = [
159
+ `Usage: ${SHORT_TOOL_NAME} speckit [options]`,
160
+ '',
161
+ ' Install Spec Kit (specify-cli) SDD slash skills into the current repo.',
162
+ ' Runs `specify init --here --ai claude --force --ignore-agent-tools` and',
163
+ ' prunes the heavy auto-generated openspec/plan + specs/ scaffolds the',
164
+ ' specify-cli emits, so it composes cleanly with the existing gx workflow.',
165
+ '',
166
+ 'Options:',
167
+ ' --target <path> Run in <path> instead of cwd',
168
+ ' --no-prune Keep the auto-generated openspec/plan + specs scaffolds',
169
+ ' --dry-run Print actions without modifying files',
170
+ ' -h, --help Show this help',
171
+ '',
172
+ 'Prerequisite:',
173
+ ` ${SPECIFY_BIN} CLI on PATH. Install with:`,
174
+ ` ${SPECKIT_INSTALL_HINT}`,
175
+ ];
176
+ console.log(lines.join('\n'));
177
+ }
178
+
179
+ function runSpeckitCommand(rawArgs) {
180
+ const args = Array.isArray(rawArgs) ? [...rawArgs] : [];
181
+ let target = process.cwd();
182
+ let prune = true;
183
+ let dryRun = false;
184
+ let force = false;
185
+
186
+ while (args.length > 0) {
187
+ const arg = args.shift();
188
+ if (arg === '-h' || arg === '--help' || arg === 'help') {
189
+ printSpeckitHelp();
190
+ return;
191
+ }
192
+ if (arg === '--target') {
193
+ const next = args.shift();
194
+ if (!next) throw new Error('--target requires a path value');
195
+ target = next;
196
+ continue;
197
+ }
198
+ if (arg === '--no-prune') {
199
+ prune = false;
200
+ continue;
201
+ }
202
+ if (arg === '--prune') {
203
+ prune = true;
204
+ continue;
205
+ }
206
+ if (arg === '--dry-run') {
207
+ dryRun = true;
208
+ continue;
209
+ }
210
+ if (arg === '--force' || arg === '--reinstall') {
211
+ force = true;
212
+ continue;
213
+ }
214
+ throw new Error(`Unknown option: ${arg}`);
215
+ }
216
+
217
+ installSpeckit({ target, prune, dryRun, force });
218
+ }
219
+
220
+ module.exports = {
221
+ runSpeckitCommand,
222
+ installSpeckit,
223
+ pruneSpecKitScaffolds,
224
+ whichSpecify,
225
+ isSpecKitAlreadyInstalled,
226
+ };