@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.
- package/README.md +94 -13
- package/package.json +3 -1
- package/skills/gitguardex/SKILL.md +13 -0
- package/skills/guardex-merge-skills-to-dev/SKILL.md +59 -0
- package/skills/gx-act/SKILL.md +82 -0
- package/src/agents/cleanup-sessions.js +126 -0
- package/src/agents/finish.js +172 -0
- package/src/agents/inspect.js +202 -0
- package/src/agents/launch.js +249 -0
- package/src/agents/registry.js +133 -0
- package/src/agents/selection-panel.js +571 -0
- package/src/agents/sessions.js +151 -0
- package/src/agents/start.js +591 -0
- package/src/agents/status.js +146 -0
- package/src/agents/terminal.js +152 -0
- package/src/budget/index.js +344 -0
- package/src/ci-init/index.js +265 -0
- package/src/cli/args.js +357 -3
- package/src/cli/commands/agents.js +364 -0
- package/src/cli/commands/bootstrap.js +92 -0
- package/src/cli/commands/branch.js +127 -0
- package/src/cli/commands/claude.js +674 -0
- package/src/cli/commands/doctor.js +268 -0
- package/src/cli/commands/finish.js +26 -0
- package/src/cli/commands/mcp.js +122 -0
- package/src/cli/commands/misc.js +304 -0
- package/src/cli/commands/pr.js +439 -0
- package/src/cli/commands/prompt.js +92 -0
- package/src/cli/commands/release.js +305 -0
- package/src/cli/commands/report.js +244 -0
- package/src/cli/commands/review.js +32 -0
- package/src/cli/commands/setup.js +242 -0
- package/src/cli/commands/status.js +338 -0
- package/src/cli/commands/watch.js +234 -0
- package/src/cli/main.js +85 -3613
- package/src/cli/shared/repo-env.js +161 -0
- package/src/cli/shared/sandbox.js +417 -0
- package/src/cli/shared/scaffolding.js +535 -0
- package/src/cli/shared/toolchain-shims.js +420 -0
- package/src/cockpit/action-runner.js +3 -0
- package/src/cockpit/actions.js +80 -0
- package/src/cockpit/control.js +1121 -0
- package/src/cockpit/index.js +426 -0
- package/src/cockpit/kitty-layout.js +549 -0
- package/src/cockpit/kitty-tree.js +144 -0
- package/src/cockpit/logs-reader.js +182 -0
- package/src/cockpit/menu.js +204 -0
- package/src/cockpit/pane-actions.js +597 -0
- package/src/cockpit/pane-menu.js +387 -0
- package/src/cockpit/projects-finder.js +178 -0
- package/src/cockpit/render.js +215 -0
- package/src/cockpit/settings-render.js +128 -0
- package/src/cockpit/settings.js +124 -0
- package/src/cockpit/shortcuts.js +24 -0
- package/src/cockpit/sidebar.js +311 -0
- package/src/cockpit/state.js +72 -0
- package/src/cockpit/theme.js +128 -0
- package/src/cockpit/welcome.js +266 -0
- package/src/context.js +304 -43
- package/src/core/runtime.js +6 -1
- package/src/doctor/index.js +45 -15
- package/src/finish/index.js +186 -7
- package/src/finish/preflight.js +177 -0
- package/src/finish/review-gate.js +182 -0
- package/src/git/index.js +511 -4
- package/src/hooks/index.js +0 -64
- package/src/kitty/command.js +101 -0
- package/src/kitty/runtime.js +250 -0
- package/src/mcp/collect.js +370 -0
- package/src/mcp/server.js +157 -0
- package/src/output/index.js +68 -2
- package/src/pr-review.js +264 -0
- package/src/pr.js +381 -0
- package/src/sandbox/index.js +13 -2
- package/src/scaffold/agent-worktree-prep.js +213 -0
- package/src/scaffold/index.js +127 -10
- package/src/speckit/index.js +226 -0
- package/src/submodule/index.js +288 -0
- package/src/terminal/index.js +45 -0
- package/src/terminal/kitty.js +622 -0
- package/src/terminal/tmux.js +125 -0
- package/src/tmux/command.js +27 -0
- package/src/tmux/session.js +89 -0
- package/src/toolchain/index.js +20 -0
- package/templates/AGENTS.monorepo-apps.md +26 -0
- package/templates/AGENTS.multiagent-safety.md +63 -323
- package/templates/AGENTS.multiagent-safety.min.md +11 -0
- package/templates/codex/skills/gitguardex/SKILL.md +2 -0
- package/templates/codex/skills/gx-act/SKILL.md +82 -0
- package/templates/githooks/pre-commit +44 -20
- package/templates/github/workflows/README.md +87 -0
- package/templates/github/workflows/ci-full.yml +55 -0
- package/templates/github/workflows/ci.yml +56 -0
- package/templates/github/workflows/cr.yml +20 -1
- package/templates/scripts/agent-branch-finish.sh +519 -23
- package/templates/scripts/agent-branch-merge.sh +4 -1
- package/templates/scripts/agent-branch-start.sh +176 -24
- package/templates/scripts/agent-preflight.sh +115 -0
- package/templates/scripts/agent-worktree-prune.sh +96 -5
- package/templates/scripts/codex-agent.sh +41 -97
- package/templates/scripts/openspec/init-plan-workspace.sh +43 -0
- package/templates/scripts/review-bot-watch.sh +31 -2
- package/templates/scripts/agent-session-state.js +0 -171
- package/templates/scripts/install-vscode-active-agents-extension.js +0 -135
- package/templates/vscode/guardex-active-agents/README.md +0 -34
- package/templates/vscode/guardex-active-agents/extension.js +0 -3782
- package/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +0 -54
- package/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg +0 -5
- package/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg +0 -7
- package/templates/vscode/guardex-active-agents/fileicons/icons/config.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +0 -5
- package/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg +0 -5
- package/templates/vscode/guardex-active-agents/icon.png +0 -0
- package/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg +0 -14
- package/templates/vscode/guardex-active-agents/package.json +0 -169
- 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
|
+
};
|
package/src/scaffold/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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:
|
|
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
|
+
};
|