xtrm-cli 2.1.4 → 2.1.14
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/dist/index.cjs +1704 -1397
- package/dist/index.cjs.map +1 -1
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/package.json +4 -1
- package/src/commands/help.ts +1 -0
- package/src/commands/install-pi.ts +117 -0
- package/src/commands/install-project.ts +134 -23
- package/src/commands/install-service-skills.ts +37 -10
- package/src/commands/install.ts +2 -0
- package/src/utils/atomic-config.ts +89 -0
- package/test/atomic-config.test.ts +83 -0
- package/test/hooks.test.ts +343 -32
- package/test/install-pi.test.ts +119 -0
- package/test/install-project.test.ts +34 -5
- package/test/install-service-skills.test.ts +20 -0
package/test/hooks.test.ts
CHANGED
|
@@ -20,11 +20,13 @@ function runHook(
|
|
|
20
20
|
hookFile: string,
|
|
21
21
|
input: Record<string, unknown>,
|
|
22
22
|
env: Record<string, string> = {},
|
|
23
|
+
cwd?: string,
|
|
23
24
|
) {
|
|
24
25
|
return spawnSync('node', [path.join(HOOKS_DIR, hookFile)], {
|
|
25
26
|
input: JSON.stringify(input),
|
|
26
27
|
encoding: 'utf8',
|
|
27
28
|
env: { ...process.env, ...env },
|
|
29
|
+
cwd,
|
|
28
30
|
});
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -48,16 +50,13 @@ function withFakeBdDir(scriptBody: string) {
|
|
|
48
50
|
|
|
49
51
|
describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
50
52
|
it('blocks Write when current branch is listed in MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
51
|
-
// Set env var to the actual current branch — without this env var the
|
|
52
|
-
// hook only checks hardcoded main/master, so a feature branch always exits 0.
|
|
53
53
|
const r = runHook(
|
|
54
54
|
'main-guard.mjs',
|
|
55
55
|
{ tool_name: 'Write', tool_input: { file_path: '/tmp/x' } },
|
|
56
56
|
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
57
57
|
);
|
|
58
|
-
expect(r.status).toBe(
|
|
58
|
+
expect(r.status).toBe(2);
|
|
59
59
|
const out = parseHookJson(r.stdout);
|
|
60
|
-
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
61
60
|
expect(out?.systemMessage).toContain(CURRENT_BRANCH);
|
|
62
61
|
});
|
|
63
62
|
|
|
@@ -76,9 +75,8 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
76
75
|
{ tool_name: 'Bash', tool_input: { command: 'cat > file.txt << EOF\nhello\nEOF' } },
|
|
77
76
|
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
78
77
|
);
|
|
79
|
-
expect(r.status).toBe(
|
|
78
|
+
expect(r.status).toBe(2);
|
|
80
79
|
const out = parseHookJson(r.stdout);
|
|
81
|
-
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
82
80
|
expect(out?.systemMessage).toContain('Bash is restricted');
|
|
83
81
|
});
|
|
84
82
|
|
|
@@ -93,6 +91,7 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
93
91
|
'git pull',
|
|
94
92
|
'gh pr list',
|
|
95
93
|
'bd list',
|
|
94
|
+
`git reset --hard origin/${CURRENT_BRANCH}`,
|
|
96
95
|
];
|
|
97
96
|
for (const command of safeCommands) {
|
|
98
97
|
const r = runHook(
|
|
@@ -116,9 +115,8 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
116
115
|
{ tool_name: 'Bash', tool_input: { command } },
|
|
117
116
|
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
118
117
|
);
|
|
119
|
-
expect(r.status, `expected
|
|
118
|
+
expect(r.status, `expected exit 2 for: ${command}`).toBe(2);
|
|
120
119
|
const out = parseHookJson(r.stdout);
|
|
121
|
-
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
122
120
|
expect(out?.systemMessage).toContain('Bash is restricted');
|
|
123
121
|
}
|
|
124
122
|
});
|
|
@@ -138,11 +136,117 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
138
136
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m "oops"' } },
|
|
139
137
|
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
140
138
|
);
|
|
141
|
-
expect(r.status).toBe(
|
|
139
|
+
expect(r.status).toBe(2);
|
|
142
140
|
const out = parseHookJson(r.stdout);
|
|
143
|
-
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
144
141
|
expect(out?.systemMessage).toContain('feature branch');
|
|
145
142
|
});
|
|
143
|
+
|
|
144
|
+
it('post-push hook sync guidance uses reset --hard, consistent with main-guard', () => {
|
|
145
|
+
const postPush = readFileSync(path.join(HOOKS_DIR, 'main-guard-post-push.mjs'), 'utf8');
|
|
146
|
+
expect(postPush).toContain('reset --hard');
|
|
147
|
+
expect(postPush).not.toContain('pull --ff-only');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('hooks.json wires Bash to main-guard so git commit protection fires', () => {
|
|
151
|
+
const hooksJson = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
|
|
152
|
+
const mainGuardEntries = hooksJson.hooks.PreToolUse.filter(
|
|
153
|
+
(h: { script: string }) => h.script === 'main-guard.mjs',
|
|
154
|
+
);
|
|
155
|
+
const matchers: string[] = mainGuardEntries.map((h: { matcher: string }) => h.matcher ?? '');
|
|
156
|
+
const coversBash = matchers.some((m: string) => m.split('|').includes('Bash'));
|
|
157
|
+
expect(coversBash, 'main-guard.mjs must have a PreToolUse entry with Bash in its matcher').toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── main-guard-post-push.mjs ────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
describe('main-guard-post-push.mjs', () => {
|
|
165
|
+
function createTempGitRepo(branch: string): string {
|
|
166
|
+
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-post-push-'));
|
|
167
|
+
spawnSync('git', ['init'], { cwd: repoDir, stdio: 'pipe' });
|
|
168
|
+
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir, stdio: 'pipe' });
|
|
169
|
+
spawnSync('git', ['config', 'user.name', 'Test User'], { cwd: repoDir, stdio: 'pipe' });
|
|
170
|
+
writeFileSync(path.join(repoDir, 'README.md'), '# test\n', 'utf8');
|
|
171
|
+
spawnSync('git', ['add', 'README.md'], { cwd: repoDir, stdio: 'pipe' });
|
|
172
|
+
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, stdio: 'pipe' });
|
|
173
|
+
const current = spawnSync('git', ['branch', '--show-current'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).stdout.trim();
|
|
174
|
+
if (current !== branch) {
|
|
175
|
+
spawnSync('git', ['checkout', '-B', branch], { cwd: repoDir, stdio: 'pipe' });
|
|
176
|
+
}
|
|
177
|
+
return repoDir;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
it('injects PR workflow reminder after successful feature-branch push command', () => {
|
|
181
|
+
const repoDir = createTempGitRepo('feature/test-push');
|
|
182
|
+
try {
|
|
183
|
+
const r = runHook(
|
|
184
|
+
'main-guard-post-push.mjs',
|
|
185
|
+
{ tool_name: 'Bash', tool_input: { command: 'git push -u origin feature/test-push' }, cwd: repoDir },
|
|
186
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
187
|
+
repoDir,
|
|
188
|
+
);
|
|
189
|
+
expect(r.status).toBe(0);
|
|
190
|
+
const out = parseHookJson(r.stdout);
|
|
191
|
+
expect(out?.systemMessage).toContain('gh pr create --fill');
|
|
192
|
+
expect(out?.systemMessage).toContain('gh pr merge --squash');
|
|
193
|
+
} finally {
|
|
194
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('does not emit reminder for non-push Bash commands', () => {
|
|
199
|
+
const repoDir = createTempGitRepo('feature/test-nopush');
|
|
200
|
+
try {
|
|
201
|
+
const r = runHook(
|
|
202
|
+
'main-guard-post-push.mjs',
|
|
203
|
+
{ tool_name: 'Bash', tool_input: { command: 'git status' }, cwd: repoDir },
|
|
204
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
205
|
+
repoDir,
|
|
206
|
+
);
|
|
207
|
+
expect(r.status).toBe(0);
|
|
208
|
+
expect(r.stdout.trim()).toBe('');
|
|
209
|
+
} finally {
|
|
210
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does not emit reminder when current branch is protected', () => {
|
|
215
|
+
const repoDir = createTempGitRepo('main');
|
|
216
|
+
try {
|
|
217
|
+
const r = runHook(
|
|
218
|
+
'main-guard-post-push.mjs',
|
|
219
|
+
{ tool_name: 'Bash', tool_input: { command: 'git push -u origin main' }, cwd: repoDir },
|
|
220
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
221
|
+
repoDir,
|
|
222
|
+
);
|
|
223
|
+
expect(r.status).toBe(0);
|
|
224
|
+
expect(r.stdout.trim()).toBe('');
|
|
225
|
+
} finally {
|
|
226
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('does not emit reminder when push command reports failure', () => {
|
|
231
|
+
const repoDir = createTempGitRepo('feature/test-failed-push');
|
|
232
|
+
try {
|
|
233
|
+
const r = runHook(
|
|
234
|
+
'main-guard-post-push.mjs',
|
|
235
|
+
{
|
|
236
|
+
tool_name: 'Bash',
|
|
237
|
+
tool_input: { command: 'git push -u origin feature/test-failed-push' },
|
|
238
|
+
tool_response: { exit_code: 1, stderr: 'remote rejected' },
|
|
239
|
+
cwd: repoDir,
|
|
240
|
+
},
|
|
241
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
242
|
+
repoDir,
|
|
243
|
+
);
|
|
244
|
+
expect(r.status).toBe(0);
|
|
245
|
+
expect(r.stdout.trim()).toBe('');
|
|
246
|
+
} finally {
|
|
247
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
146
250
|
});
|
|
147
251
|
|
|
148
252
|
// ── beads-gate-utils.mjs ─────────────────────────────────────────────────────
|
|
@@ -236,42 +340,249 @@ describe('beads-stop-gate.mjs', () => {
|
|
|
236
340
|
const content = readFileSync(path.join(HOOKS_DIR, 'beads-stop-gate.mjs'), 'utf8');
|
|
237
341
|
expect(content).toContain("from './beads-gate-utils.mjs'");
|
|
238
342
|
});
|
|
343
|
+
|
|
344
|
+
it('allows stop (exit 0) when session has a stale claim but no in_progress issues', () => {
|
|
345
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-beads-stopgate-'));
|
|
346
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
347
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
348
|
+
set -euo pipefail
|
|
349
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
350
|
+
echo "jaggers-stale-claim"
|
|
351
|
+
exit 0
|
|
352
|
+
fi
|
|
353
|
+
if [[ "$1" == "list" ]]; then
|
|
354
|
+
cat <<'EOF'
|
|
355
|
+
|
|
356
|
+
--------------------------------------------------------------------------------
|
|
357
|
+
Total: 0 issues (0 open, 0 in progress)
|
|
358
|
+
EOF
|
|
359
|
+
exit 0
|
|
360
|
+
fi
|
|
361
|
+
exit 1
|
|
362
|
+
`);
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const r = runHook(
|
|
366
|
+
'beads-stop-gate.mjs',
|
|
367
|
+
{ session_id: 'session-stale-claim', cwd: projectDir },
|
|
368
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
369
|
+
);
|
|
370
|
+
expect(r.status).toBe(0);
|
|
371
|
+
} finally {
|
|
372
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
373
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
239
376
|
});
|
|
240
377
|
|
|
241
|
-
// ── beads-commit-gate.mjs ─────────────────────────────────────────────────────
|
|
242
378
|
|
|
243
|
-
describe('beads-
|
|
379
|
+
describe('beads-memory-gate.mjs', () => {
|
|
244
380
|
it('fails open (exit 0) when no .beads directory exists', () => {
|
|
245
|
-
const r = runHook('beads-
|
|
246
|
-
session_id: 'test',
|
|
247
|
-
tool_name: 'Bash',
|
|
248
|
-
tool_input: { command: 'git commit -m test' },
|
|
249
|
-
cwd: '/tmp',
|
|
250
|
-
});
|
|
381
|
+
const r = runHook('beads-memory-gate.mjs', { session_id: 'test', cwd: '/tmp' });
|
|
251
382
|
expect(r.status).toBe(0);
|
|
252
383
|
});
|
|
253
384
|
|
|
254
|
-
it('
|
|
255
|
-
const
|
|
256
|
-
|
|
385
|
+
it('allows stop (exit 0) when marker file exists', () => {
|
|
386
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-memgate-'));
|
|
387
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
388
|
+
writeFileSync(path.join(projectDir, '.beads', '.memory-gate-done'), '');
|
|
389
|
+
try {
|
|
390
|
+
const r = runHook('beads-memory-gate.mjs', { session_id: 'test', cwd: projectDir });
|
|
391
|
+
expect(r.status).toBe(0);
|
|
392
|
+
} finally {
|
|
393
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('allows stop (exit 0) when no closed issues exist', () => {
|
|
398
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-memgate-'));
|
|
399
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
400
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
401
|
+
set -euo pipefail
|
|
402
|
+
if [[ "$1" == "list" ]]; then
|
|
403
|
+
cat <<'EOF'
|
|
404
|
+
|
|
405
|
+
--------------------------------------------------------------------------------
|
|
406
|
+
Total: 0 issues (0 open, 0 in progress)
|
|
407
|
+
EOF
|
|
408
|
+
exit 0
|
|
409
|
+
fi
|
|
410
|
+
exit 1
|
|
411
|
+
`);
|
|
412
|
+
try {
|
|
413
|
+
const r = runHook(
|
|
414
|
+
'beads-memory-gate.mjs',
|
|
415
|
+
{ session_id: 'test', cwd: projectDir },
|
|
416
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
417
|
+
);
|
|
418
|
+
expect(r.status).toBe(0);
|
|
419
|
+
} finally {
|
|
420
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
421
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('blocks stop (exit 2) when closed issues exist and no marker', () => {
|
|
426
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-memgate-'));
|
|
427
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
428
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
429
|
+
set -euo pipefail
|
|
430
|
+
if [[ "$1" == "list" ]]; then
|
|
431
|
+
cat <<'EOF'
|
|
432
|
+
✓ issue-abc P2 Fix the thing
|
|
433
|
+
|
|
434
|
+
--------------------------------------------------------------------------------
|
|
435
|
+
Total: 1 issues (0 open, 0 in progress, 1 closed)
|
|
436
|
+
EOF
|
|
437
|
+
exit 0
|
|
438
|
+
fi
|
|
439
|
+
exit 1
|
|
440
|
+
`);
|
|
441
|
+
try {
|
|
442
|
+
const r = runHook(
|
|
443
|
+
'beads-memory-gate.mjs',
|
|
444
|
+
{ session_id: 'test', cwd: projectDir },
|
|
445
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
446
|
+
);
|
|
447
|
+
expect(r.status).toBe(2);
|
|
448
|
+
expect(r.stderr).toContain('MEMORY GATE');
|
|
449
|
+
} finally {
|
|
450
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
451
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
452
|
+
}
|
|
257
453
|
});
|
|
258
454
|
});
|
|
259
455
|
|
|
260
|
-
// ── beads-close-memory-prompt.mjs ────────────────────────────────────────────
|
|
261
456
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
457
|
+
// ── tdd-guard-pretool-bridge.cjs ─────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
const TDD_BRIDGE_DIR = path.join(__dirname, '../../project-skills/tdd-guard/.claude/hooks');
|
|
460
|
+
|
|
461
|
+
describe('tdd-guard-pretool-bridge.cjs', () => {
|
|
462
|
+
it('does not forward tdd-guard stderr when stdout already contains the message', () => {
|
|
463
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-'));
|
|
464
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
465
|
+
// Simulate tdd-guard writing the same message to both stdout and stderr (the bug)
|
|
466
|
+
writeFileSync(fakeBin, `#!/usr/bin/env bash\nMSG='{"reason":"Premature implementation"}'\necho "$MSG"\necho "$MSG" >&2\nexit 2\n`, { encoding: 'utf8' });
|
|
467
|
+
chmodSync(fakeBin, 0o755);
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
471
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.ts' } }),
|
|
472
|
+
encoding: 'utf8',
|
|
473
|
+
env: { ...process.env, PATH: `${fakeDir}:${process.env.PATH ?? ''}` },
|
|
474
|
+
});
|
|
475
|
+
expect(r.stderr).toBe('');
|
|
476
|
+
} finally {
|
|
477
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('forces sdk validation client and strips api-mode env vars', () => {
|
|
482
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-env-'));
|
|
483
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
484
|
+
writeFileSync(
|
|
485
|
+
fakeBin,
|
|
486
|
+
`#!/usr/bin/env bash
|
|
487
|
+
if [[ "$VALIDATION_CLIENT" != "sdk" ]]; then exit 12; fi
|
|
488
|
+
if [[ -n "\${MODEL_TYPE:-}" ]]; then exit 13; fi
|
|
489
|
+
if [[ -n "\${TDD_GUARD_ANTHROPIC_API_KEY:-}" ]]; then exit 14; fi
|
|
490
|
+
if [[ -n "\${ANTHROPIC_API_KEY:-}" ]]; then exit 15; fi
|
|
491
|
+
if [[ -n "\${ANTHROPIC_BASE_URL:-}" ]]; then exit 16; fi
|
|
492
|
+
exit 0
|
|
493
|
+
`,
|
|
494
|
+
{ encoding: 'utf8' },
|
|
495
|
+
);
|
|
496
|
+
chmodSync(fakeBin, 0o755);
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
500
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.py' } }),
|
|
501
|
+
encoding: 'utf8',
|
|
502
|
+
env: {
|
|
503
|
+
...process.env,
|
|
504
|
+
PATH: `${fakeDir}:${process.env.PATH ?? ''}`,
|
|
505
|
+
VALIDATION_CLIENT: 'api',
|
|
506
|
+
MODEL_TYPE: 'anthropic_api',
|
|
507
|
+
TDD_GUARD_ANTHROPIC_API_KEY: 'x',
|
|
508
|
+
ANTHROPIC_API_KEY: 'y',
|
|
509
|
+
ANTHROPIC_BASE_URL: 'https://example.invalid',
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
expect(r.status).toBe(0);
|
|
513
|
+
} finally {
|
|
514
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('fails open for known API response JSON-parse errors', () => {
|
|
519
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-apierr-'));
|
|
520
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
521
|
+
writeFileSync(
|
|
522
|
+
fakeBin,
|
|
523
|
+
`#!/usr/bin/env bash
|
|
524
|
+
echo 'Error during validation: Unexpected token '\\''A'\\'', "API Error:"... is not valid JSON'
|
|
525
|
+
exit 2
|
|
526
|
+
`,
|
|
527
|
+
{ encoding: 'utf8' },
|
|
528
|
+
);
|
|
529
|
+
chmodSync(fakeBin, 0o755);
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
533
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.py' } }),
|
|
534
|
+
encoding: 'utf8',
|
|
535
|
+
env: { ...process.env, PATH: `${fakeDir}:${process.env.PATH ?? ''}` },
|
|
536
|
+
});
|
|
537
|
+
expect(r.status).toBe(0);
|
|
538
|
+
} finally {
|
|
539
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
// ── gitnexus-impact-reminder.py ──────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
function runPythonHook(
|
|
548
|
+
hookFile: string,
|
|
549
|
+
input: Record<string, unknown>,
|
|
550
|
+
) {
|
|
551
|
+
return spawnSync('python3', [path.join(HOOKS_DIR, hookFile)], {
|
|
552
|
+
input: JSON.stringify(input),
|
|
553
|
+
encoding: 'utf8',
|
|
554
|
+
env: { ...process.env },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
describe('gitnexus-impact-reminder.py', () => {
|
|
559
|
+
it('injects additionalContext when prompt contains an edit-intent keyword', () => {
|
|
560
|
+
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
561
|
+
hook_event_name: 'UserPromptSubmit',
|
|
562
|
+
prompt: 'fix the broken auth logic in login.ts',
|
|
269
563
|
});
|
|
270
564
|
expect(r.status).toBe(0);
|
|
565
|
+
const out = parseHookJson(r.stdout);
|
|
566
|
+
expect(out?.hookSpecificOutput?.additionalContext).toContain('gitnexus impact');
|
|
271
567
|
});
|
|
272
568
|
|
|
273
|
-
it('
|
|
274
|
-
const
|
|
275
|
-
|
|
569
|
+
it('does nothing (no output) when prompt has no edit-intent keywords', () => {
|
|
570
|
+
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
571
|
+
hook_event_name: 'UserPromptSubmit',
|
|
572
|
+
prompt: 'explain how the beads gate works',
|
|
573
|
+
});
|
|
574
|
+
expect(r.status).toBe(0);
|
|
575
|
+
expect(r.stdout.trim()).toBe('');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('does nothing for non-UserPromptSubmit events', () => {
|
|
579
|
+
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
580
|
+
hook_event_name: 'PreToolUse',
|
|
581
|
+
tool_name: 'Edit',
|
|
582
|
+
tool_input: { file_path: 'foo.ts' },
|
|
583
|
+
prompt: 'fix something',
|
|
584
|
+
});
|
|
585
|
+
expect(r.status).toBe(0);
|
|
586
|
+
expect(r.stdout.trim()).toBe('');
|
|
276
587
|
});
|
|
277
588
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createInstallPiCommand } from '../src/commands/install-pi.js';
|
|
3
|
+
|
|
4
|
+
describe('createInstallPiCommand', () => {
|
|
5
|
+
it('exports a createInstallPiCommand function', () => {
|
|
6
|
+
expect(typeof createInstallPiCommand).toBe('function');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns a Command named "pi"', () => {
|
|
10
|
+
const cmd = createInstallPiCommand();
|
|
11
|
+
expect((cmd as any).name()).toBe('pi');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('fillTemplate replaces {{PLACEHOLDERS}} with values', async () => {
|
|
15
|
+
const { fillTemplate } = await import('../src/commands/install-pi.js');
|
|
16
|
+
expect(fillTemplate('{"k":"{{MY_KEY}}"}' , { MY_KEY: 'abc' })).toBe('{"k":"abc"}');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('fillTemplate leaves missing placeholders empty', async () => {
|
|
20
|
+
const { fillTemplate } = await import('../src/commands/install-pi.js');
|
|
21
|
+
expect(fillTemplate('{"k":"{{MISSING}}"}', {})).toBe('{"k":""}');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('models.json.template contains {{DASHSCOPE_API_KEY}}', () => {
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const p = require('node:path');
|
|
27
|
+
const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'models.json.template'), 'utf8');
|
|
28
|
+
expect(content).toContain('{{DASHSCOPE_API_KEY}}');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('auth.json.template contains {{DASHSCOPE_API_KEY}} and {{ZAI_API_KEY}}', () => {
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const p = require('node:path');
|
|
34
|
+
const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'auth.json.template'), 'utf8');
|
|
35
|
+
expect(content).toContain('{{DASHSCOPE_API_KEY}}');
|
|
36
|
+
expect(content).toContain('{{ZAI_API_KEY}}');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('auth.json.template contains no real API keys or tokens', () => {
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
const p = require('node:path');
|
|
42
|
+
const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'auth.json.template'), 'utf8');
|
|
43
|
+
expect(content).not.toMatch(/sk-[a-zA-Z0-9]{20,}/);
|
|
44
|
+
expect(content).not.toMatch(/ya29\.[a-zA-Z0-9_-]{20,}/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('settings.json.template includes pi-serena-tools package', () => {
|
|
48
|
+
const fs = require('node:fs');
|
|
49
|
+
const p = require('node:path');
|
|
50
|
+
const settings = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'settings.json.template'), 'utf8'));
|
|
51
|
+
expect(settings.packages).toContain('npm:pi-serena-tools');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('install-schema.json defines DASHSCOPE_API_KEY and ZAI_API_KEY fields', () => {
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const p = require('node:path');
|
|
57
|
+
const schema = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'install-schema.json'), 'utf8'));
|
|
58
|
+
const keys = schema.fields.map((f) => f.key);
|
|
59
|
+
expect(keys).toContain('DASHSCOPE_API_KEY');
|
|
60
|
+
expect(keys).toContain('ZAI_API_KEY');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('install-schema.json lists anthropic and qwen-cli as oauth_providers', () => {
|
|
64
|
+
const fs = require('node:fs');
|
|
65
|
+
const p = require('node:path');
|
|
66
|
+
const schema = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'install-schema.json'), 'utf8'));
|
|
67
|
+
const keys = schema.oauth_providers.map((o) => o.key);
|
|
68
|
+
expect(keys).toContain('anthropic');
|
|
69
|
+
expect(keys).toContain('qwen-cli');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('extensions directory contains all expected .ts files', () => {
|
|
73
|
+
const fs = require('node:fs');
|
|
74
|
+
const p = require('node:path');
|
|
75
|
+
const extDir = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions');
|
|
76
|
+
const files = ['auto-session-name.ts','auto-update.ts','bg-process.ts','compact-header.ts','custom-footer.ts','git-checkpoint.ts','git-guard.ts','safe-guard.ts','todo.ts'];
|
|
77
|
+
for (const f of files) expect(fs.existsSync(p.join(extDir, f))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('custom-provider-qwen-cli extension has index.ts and package.json', () => {
|
|
81
|
+
const fs = require('node:fs');
|
|
82
|
+
const p = require('node:path');
|
|
83
|
+
const base = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions', 'custom-provider-qwen-cli');
|
|
84
|
+
expect(fs.existsSync(p.join(base, 'index.ts'))).toBe(true);
|
|
85
|
+
expect(fs.existsSync(p.join(base, 'package.json'))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('readExistingPiValues extracts DASHSCOPE_API_KEY from existing auth.json', async () => {
|
|
89
|
+
const { readExistingPiValues } = await import('../src/commands/install-pi.js?t=' + Date.now());
|
|
90
|
+
const tmpDir = require('node:fs').mkdtempSync(require('node:path').join(require('node:os').tmpdir(), 'pi-test-'));
|
|
91
|
+
require('node:fs').writeFileSync(require('node:path').join(tmpDir, 'auth.json'), JSON.stringify({ dashscope: { type: 'api_key', key: 'sk-existing-123' } }));
|
|
92
|
+
const result = readExistingPiValues(tmpDir);
|
|
93
|
+
require('node:fs').rmSync(tmpDir, { recursive: true });
|
|
94
|
+
expect(result['DASHSCOPE_API_KEY']).toBe('sk-existing-123');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('readExistingPiValues extracts ZAI_API_KEY from existing auth.json', async () => {
|
|
98
|
+
const { readExistingPiValues } = await import('../src/commands/install-pi.js?t=' + Date.now());
|
|
99
|
+
const tmpDir = require('node:fs').mkdtempSync(require('node:path').join(require('node:os').tmpdir(), 'pi-test-'));
|
|
100
|
+
require('node:fs').writeFileSync(require('node:path').join(tmpDir, 'auth.json'), JSON.stringify({ zai: { type: 'api_key', key: 'zai-existing-456' } }));
|
|
101
|
+
const result = readExistingPiValues(tmpDir);
|
|
102
|
+
require('node:fs').rmSync(tmpDir, { recursive: true });
|
|
103
|
+
expect(result['ZAI_API_KEY']).toBe('zai-existing-456');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('readExistingPiValues returns empty object when auth.json missing', async () => {
|
|
107
|
+
const { readExistingPiValues } = await import('../src/commands/install-pi.js?t=' + Date.now());
|
|
108
|
+
expect(readExistingPiValues('/nonexistent/path')).toEqual({});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('readExistingPiValues extracts DASHSCOPE_API_KEY from models.json when auth.json missing', async () => {
|
|
112
|
+
const { readExistingPiValues } = await import('../src/commands/install-pi.js?t=models' + Date.now());
|
|
113
|
+
const tmpDir = require('node:fs').mkdtempSync(require('node:path').join(require('node:os').tmpdir(), 'pi-test-'));
|
|
114
|
+
require('node:fs').writeFileSync(require('node:path').join(tmpDir, 'models.json'), JSON.stringify({ providers: { dashscope: { apiKey: 'sk-from-models-789' } } }));
|
|
115
|
+
const result = readExistingPiValues(tmpDir);
|
|
116
|
+
require('node:fs').rmSync(tmpDir, { recursive: true });
|
|
117
|
+
expect(result['DASHSCOPE_API_KEY']).toBe('sk-from-models-789');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -15,13 +15,14 @@ import {
|
|
|
15
15
|
} from '../src/commands/install-project.js';
|
|
16
16
|
|
|
17
17
|
describe('buildProjectInitGuide', () => {
|
|
18
|
-
it('includes
|
|
18
|
+
it('includes complete onboarding guidance (quality gates, beads workflow, and git workflow)', () => {
|
|
19
19
|
const guide = buildProjectInitGuide();
|
|
20
|
-
expect(guide).toContain('
|
|
21
|
-
expect(guide).toContain('py-quality-gate');
|
|
20
|
+
expect(guide).toContain('quality-gates');
|
|
22
21
|
expect(guide).toContain('tdd-guard');
|
|
23
|
-
expect(guide
|
|
24
|
-
expect(guide.toLowerCase()).toContain('
|
|
22
|
+
expect(guide).toContain('service-skills-set');
|
|
23
|
+
expect(guide.toLowerCase()).toContain('beads workflow');
|
|
24
|
+
expect(guide).toContain('bd ready --json');
|
|
25
|
+
expect(guide).toContain('gh pr create --fill');
|
|
25
26
|
expect(guide.toLowerCase()).toContain('service-skills-set');
|
|
26
27
|
});
|
|
27
28
|
});
|
|
@@ -86,6 +87,34 @@ describe('deepMergeHooks', () => {
|
|
|
86
87
|
expect(matcher).toContain('mcp__serena__insert_after_symbol');
|
|
87
88
|
expect(matcher).toContain('mcp__serena__insert_before_symbol');
|
|
88
89
|
});
|
|
90
|
+
|
|
91
|
+
it('upgrades matcher when command path differs but hook script is the same', () => {
|
|
92
|
+
const existing = {
|
|
93
|
+
hooks: {
|
|
94
|
+
PostToolUse: [{
|
|
95
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
96
|
+
hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/hooks/quality-check.py"' }],
|
|
97
|
+
}],
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const incoming = {
|
|
102
|
+
hooks: {
|
|
103
|
+
PostToolUse: [{
|
|
104
|
+
matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
105
|
+
hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.py"' }],
|
|
106
|
+
}],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const merged = deepMergeHooks(existing, incoming);
|
|
111
|
+
expect(merged.hooks.PostToolUse).toHaveLength(1);
|
|
112
|
+
const matcher = merged.hooks.PostToolUse[0].matcher as string;
|
|
113
|
+
expect(matcher).toContain('mcp__serena__rename_symbol');
|
|
114
|
+
expect(matcher).toContain('mcp__serena__replace_symbol_body');
|
|
115
|
+
expect(matcher).toContain('mcp__serena__insert_after_symbol');
|
|
116
|
+
expect(matcher).toContain('mcp__serena__insert_before_symbol');
|
|
117
|
+
});
|
|
89
118
|
});
|
|
90
119
|
|
|
91
120
|
describe('extractReadmeDescription', () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import fsExtra from 'fs-extra';
|
|
@@ -108,4 +109,23 @@ describe('installGitHooks', () => {
|
|
|
108
109
|
const count = (content.match(/# \[jaggers\] doc-reminder/g) ?? []).length;
|
|
109
110
|
expect(count).toBe(1);
|
|
110
111
|
});
|
|
112
|
+
|
|
113
|
+
it('chains hooks into configured core.hooksPath when beads owns hooks path', async () => {
|
|
114
|
+
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
|
115
|
+
spawnSync('git', ['config', 'core.hooksPath', '.beads/hooks'], { cwd: tmpDir, stdio: 'pipe' });
|
|
116
|
+
|
|
117
|
+
await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
|
|
118
|
+
|
|
119
|
+
const beadsPreCommit = path.join(tmpDir, '.beads', 'hooks', 'pre-commit');
|
|
120
|
+
const beadsPrePush = path.join(tmpDir, '.beads', 'hooks', 'pre-push');
|
|
121
|
+
expect(await fsExtra.pathExists(beadsPreCommit)).toBe(true);
|
|
122
|
+
expect(await fsExtra.pathExists(beadsPrePush)).toBe(true);
|
|
123
|
+
|
|
124
|
+
const preCommitContent = await fsExtra.readFile(beadsPreCommit, 'utf8');
|
|
125
|
+
const prePushContent = await fsExtra.readFile(beadsPrePush, 'utf8');
|
|
126
|
+
expect(preCommitContent).toContain('# [jaggers] chain-githooks');
|
|
127
|
+
expect(prePushContent).toContain('# [jaggers] chain-githooks');
|
|
128
|
+
expect(preCommitContent).toContain(path.join(tmpDir, '.githooks', 'pre-commit'));
|
|
129
|
+
expect(prePushContent).toContain(path.join(tmpDir, '.githooks', 'pre-push'));
|
|
130
|
+
});
|
|
111
131
|
});
|