xtrm-cli 2.1.4 → 2.1.11
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/cli/src/commands/install-pi.ts +5 -0
- package/dist/index.cjs +1681 -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 +95 -0
- package/src/commands/install-project.ts +133 -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 +249 -34
- package/test/install-pi.test.ts +87 -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
|
|
|
@@ -116,9 +114,8 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
116
114
|
{ tool_name: 'Bash', tool_input: { command } },
|
|
117
115
|
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
118
116
|
);
|
|
119
|
-
expect(r.status, `expected
|
|
117
|
+
expect(r.status, `expected exit 2 for: ${command}`).toBe(2);
|
|
120
118
|
const out = parseHookJson(r.stdout);
|
|
121
|
-
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
122
119
|
expect(out?.systemMessage).toContain('Bash is restricted');
|
|
123
120
|
}
|
|
124
121
|
});
|
|
@@ -138,13 +135,102 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
138
135
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m "oops"' } },
|
|
139
136
|
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
140
137
|
);
|
|
141
|
-
expect(r.status).toBe(
|
|
138
|
+
expect(r.status).toBe(2);
|
|
142
139
|
const out = parseHookJson(r.stdout);
|
|
143
|
-
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
144
140
|
expect(out?.systemMessage).toContain('feature branch');
|
|
145
141
|
});
|
|
146
142
|
});
|
|
147
143
|
|
|
144
|
+
// ── main-guard-post-push.mjs ────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('main-guard-post-push.mjs', () => {
|
|
147
|
+
function createTempGitRepo(branch: string): string {
|
|
148
|
+
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-post-push-'));
|
|
149
|
+
spawnSync('git', ['init'], { cwd: repoDir, stdio: 'pipe' });
|
|
150
|
+
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir, stdio: 'pipe' });
|
|
151
|
+
spawnSync('git', ['config', 'user.name', 'Test User'], { cwd: repoDir, stdio: 'pipe' });
|
|
152
|
+
writeFileSync(path.join(repoDir, 'README.md'), '# test\n', 'utf8');
|
|
153
|
+
spawnSync('git', ['add', 'README.md'], { cwd: repoDir, stdio: 'pipe' });
|
|
154
|
+
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, stdio: 'pipe' });
|
|
155
|
+
const current = spawnSync('git', ['branch', '--show-current'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).stdout.trim();
|
|
156
|
+
if (current !== branch) {
|
|
157
|
+
spawnSync('git', ['checkout', '-B', branch], { cwd: repoDir, stdio: 'pipe' });
|
|
158
|
+
}
|
|
159
|
+
return repoDir;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
it('injects PR workflow reminder after successful feature-branch push command', () => {
|
|
163
|
+
const repoDir = createTempGitRepo('feature/test-push');
|
|
164
|
+
try {
|
|
165
|
+
const r = runHook(
|
|
166
|
+
'main-guard-post-push.mjs',
|
|
167
|
+
{ tool_name: 'Bash', tool_input: { command: 'git push -u origin feature/test-push' }, cwd: repoDir },
|
|
168
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
169
|
+
repoDir,
|
|
170
|
+
);
|
|
171
|
+
expect(r.status).toBe(0);
|
|
172
|
+
const out = parseHookJson(r.stdout);
|
|
173
|
+
expect(out?.systemMessage).toContain('gh pr create --fill');
|
|
174
|
+
expect(out?.systemMessage).toContain('gh pr merge --squash');
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('does not emit reminder for non-push Bash commands', () => {
|
|
181
|
+
const repoDir = createTempGitRepo('feature/test-nopush');
|
|
182
|
+
try {
|
|
183
|
+
const r = runHook(
|
|
184
|
+
'main-guard-post-push.mjs',
|
|
185
|
+
{ tool_name: 'Bash', tool_input: { command: 'git status' }, cwd: repoDir },
|
|
186
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
187
|
+
repoDir,
|
|
188
|
+
);
|
|
189
|
+
expect(r.status).toBe(0);
|
|
190
|
+
expect(r.stdout.trim()).toBe('');
|
|
191
|
+
} finally {
|
|
192
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('does not emit reminder when current branch is protected', () => {
|
|
197
|
+
const repoDir = createTempGitRepo('main');
|
|
198
|
+
try {
|
|
199
|
+
const r = runHook(
|
|
200
|
+
'main-guard-post-push.mjs',
|
|
201
|
+
{ tool_name: 'Bash', tool_input: { command: 'git push -u origin main' }, cwd: repoDir },
|
|
202
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
203
|
+
repoDir,
|
|
204
|
+
);
|
|
205
|
+
expect(r.status).toBe(0);
|
|
206
|
+
expect(r.stdout.trim()).toBe('');
|
|
207
|
+
} finally {
|
|
208
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('does not emit reminder when push command reports failure', () => {
|
|
213
|
+
const repoDir = createTempGitRepo('feature/test-failed-push');
|
|
214
|
+
try {
|
|
215
|
+
const r = runHook(
|
|
216
|
+
'main-guard-post-push.mjs',
|
|
217
|
+
{
|
|
218
|
+
tool_name: 'Bash',
|
|
219
|
+
tool_input: { command: 'git push -u origin feature/test-failed-push' },
|
|
220
|
+
tool_response: { exit_code: 1, stderr: 'remote rejected' },
|
|
221
|
+
cwd: repoDir,
|
|
222
|
+
},
|
|
223
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
224
|
+
repoDir,
|
|
225
|
+
);
|
|
226
|
+
expect(r.status).toBe(0);
|
|
227
|
+
expect(r.stdout.trim()).toBe('');
|
|
228
|
+
} finally {
|
|
229
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
148
234
|
// ── beads-gate-utils.mjs ─────────────────────────────────────────────────────
|
|
149
235
|
|
|
150
236
|
describe('beads-gate-utils.mjs — module integrity', () => {
|
|
@@ -236,42 +322,171 @@ describe('beads-stop-gate.mjs', () => {
|
|
|
236
322
|
const content = readFileSync(path.join(HOOKS_DIR, 'beads-stop-gate.mjs'), 'utf8');
|
|
237
323
|
expect(content).toContain("from './beads-gate-utils.mjs'");
|
|
238
324
|
});
|
|
325
|
+
|
|
326
|
+
it('allows stop (exit 0) when session has a stale claim but no in_progress issues', () => {
|
|
327
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-beads-stopgate-'));
|
|
328
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
329
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
330
|
+
set -euo pipefail
|
|
331
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
332
|
+
echo "jaggers-stale-claim"
|
|
333
|
+
exit 0
|
|
334
|
+
fi
|
|
335
|
+
if [[ "$1" == "list" ]]; then
|
|
336
|
+
cat <<'EOF'
|
|
337
|
+
|
|
338
|
+
--------------------------------------------------------------------------------
|
|
339
|
+
Total: 0 issues (0 open, 0 in progress)
|
|
340
|
+
EOF
|
|
341
|
+
exit 0
|
|
342
|
+
fi
|
|
343
|
+
exit 1
|
|
344
|
+
`);
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const r = runHook(
|
|
348
|
+
'beads-stop-gate.mjs',
|
|
349
|
+
{ session_id: 'session-stale-claim', cwd: projectDir },
|
|
350
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
351
|
+
);
|
|
352
|
+
expect(r.status).toBe(0);
|
|
353
|
+
} finally {
|
|
354
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
355
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
239
358
|
});
|
|
240
359
|
|
|
241
|
-
// ── beads-commit-gate.mjs ─────────────────────────────────────────────────────
|
|
242
360
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
361
|
+
// ── tdd-guard-pretool-bridge.cjs ─────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
const TDD_BRIDGE_DIR = path.join(__dirname, '../../project-skills/tdd-guard/.claude/hooks');
|
|
364
|
+
|
|
365
|
+
describe('tdd-guard-pretool-bridge.cjs', () => {
|
|
366
|
+
it('does not forward tdd-guard stderr when stdout already contains the message', () => {
|
|
367
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-'));
|
|
368
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
369
|
+
// Simulate tdd-guard writing the same message to both stdout and stderr (the bug)
|
|
370
|
+
writeFileSync(fakeBin, `#!/usr/bin/env bash\nMSG='{"reason":"Premature implementation"}'\necho "$MSG"\necho "$MSG" >&2\nexit 2\n`, { encoding: 'utf8' });
|
|
371
|
+
chmodSync(fakeBin, 0o755);
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
375
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.ts' } }),
|
|
376
|
+
encoding: 'utf8',
|
|
377
|
+
env: { ...process.env, PATH: `${fakeDir}:${process.env.PATH ?? ''}` },
|
|
378
|
+
});
|
|
379
|
+
expect(r.stderr).toBe('');
|
|
380
|
+
} finally {
|
|
381
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
382
|
+
}
|
|
252
383
|
});
|
|
253
384
|
|
|
254
|
-
it('
|
|
255
|
-
const
|
|
256
|
-
|
|
385
|
+
it('forces sdk validation client and strips api-mode env vars', () => {
|
|
386
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-env-'));
|
|
387
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
388
|
+
writeFileSync(
|
|
389
|
+
fakeBin,
|
|
390
|
+
`#!/usr/bin/env bash
|
|
391
|
+
if [[ "$VALIDATION_CLIENT" != "sdk" ]]; then exit 12; fi
|
|
392
|
+
if [[ -n "\${MODEL_TYPE:-}" ]]; then exit 13; fi
|
|
393
|
+
if [[ -n "\${TDD_GUARD_ANTHROPIC_API_KEY:-}" ]]; then exit 14; fi
|
|
394
|
+
if [[ -n "\${ANTHROPIC_API_KEY:-}" ]]; then exit 15; fi
|
|
395
|
+
if [[ -n "\${ANTHROPIC_BASE_URL:-}" ]]; then exit 16; fi
|
|
396
|
+
exit 0
|
|
397
|
+
`,
|
|
398
|
+
{ encoding: 'utf8' },
|
|
399
|
+
);
|
|
400
|
+
chmodSync(fakeBin, 0o755);
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
404
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.py' } }),
|
|
405
|
+
encoding: 'utf8',
|
|
406
|
+
env: {
|
|
407
|
+
...process.env,
|
|
408
|
+
PATH: `${fakeDir}:${process.env.PATH ?? ''}`,
|
|
409
|
+
VALIDATION_CLIENT: 'api',
|
|
410
|
+
MODEL_TYPE: 'anthropic_api',
|
|
411
|
+
TDD_GUARD_ANTHROPIC_API_KEY: 'x',
|
|
412
|
+
ANTHROPIC_API_KEY: 'y',
|
|
413
|
+
ANTHROPIC_BASE_URL: 'https://example.invalid',
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
expect(r.status).toBe(0);
|
|
417
|
+
} finally {
|
|
418
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('fails open for known API response JSON-parse errors', () => {
|
|
423
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-apierr-'));
|
|
424
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
425
|
+
writeFileSync(
|
|
426
|
+
fakeBin,
|
|
427
|
+
`#!/usr/bin/env bash
|
|
428
|
+
echo 'Error during validation: Unexpected token '\\''A'\\'', "API Error:"... is not valid JSON'
|
|
429
|
+
exit 2
|
|
430
|
+
`,
|
|
431
|
+
{ encoding: 'utf8' },
|
|
432
|
+
);
|
|
433
|
+
chmodSync(fakeBin, 0o755);
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
437
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.py' } }),
|
|
438
|
+
encoding: 'utf8',
|
|
439
|
+
env: { ...process.env, PATH: `${fakeDir}:${process.env.PATH ?? ''}` },
|
|
440
|
+
});
|
|
441
|
+
expect(r.status).toBe(0);
|
|
442
|
+
} finally {
|
|
443
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
444
|
+
}
|
|
257
445
|
});
|
|
258
446
|
});
|
|
259
447
|
|
|
260
|
-
// ── beads-close-memory-prompt.mjs ────────────────────────────────────────────
|
|
261
448
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
449
|
+
// ── gitnexus-impact-reminder.py ──────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
function runPythonHook(
|
|
452
|
+
hookFile: string,
|
|
453
|
+
input: Record<string, unknown>,
|
|
454
|
+
) {
|
|
455
|
+
return spawnSync('python3', [path.join(HOOKS_DIR, hookFile)], {
|
|
456
|
+
input: JSON.stringify(input),
|
|
457
|
+
encoding: 'utf8',
|
|
458
|
+
env: { ...process.env },
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
describe('gitnexus-impact-reminder.py', () => {
|
|
463
|
+
it('injects additionalContext when prompt contains an edit-intent keyword', () => {
|
|
464
|
+
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
465
|
+
hook_event_name: 'UserPromptSubmit',
|
|
466
|
+
prompt: 'fix the broken auth logic in login.ts',
|
|
269
467
|
});
|
|
270
468
|
expect(r.status).toBe(0);
|
|
469
|
+
const out = parseHookJson(r.stdout);
|
|
470
|
+
expect(out?.hookSpecificOutput?.additionalContext).toContain('gitnexus impact');
|
|
271
471
|
});
|
|
272
472
|
|
|
273
|
-
it('
|
|
274
|
-
const
|
|
275
|
-
|
|
473
|
+
it('does nothing (no output) when prompt has no edit-intent keywords', () => {
|
|
474
|
+
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
475
|
+
hook_event_name: 'UserPromptSubmit',
|
|
476
|
+
prompt: 'explain how the beads gate works',
|
|
477
|
+
});
|
|
478
|
+
expect(r.status).toBe(0);
|
|
479
|
+
expect(r.stdout.trim()).toBe('');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('does nothing for non-UserPromptSubmit events', () => {
|
|
483
|
+
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
484
|
+
hook_event_name: 'PreToolUse',
|
|
485
|
+
tool_name: 'Edit',
|
|
486
|
+
tool_input: { file_path: 'foo.ts' },
|
|
487
|
+
prompt: 'fix something',
|
|
488
|
+
});
|
|
489
|
+
expect(r.status).toBe(0);
|
|
490
|
+
expect(r.stdout.trim()).toBe('');
|
|
276
491
|
});
|
|
277
492
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
});
|
|
@@ -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
|
});
|