xtrm-cli 2.1.11 → 2.1.16
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 +56 -1
- package/dist/index.cjs.map +1 -1
- package/package.json +1 -1
- package/src/commands/install-pi.ts +23 -1
- package/src/commands/install-project.ts +1 -0
- package/src/commands/install.ts +37 -0
- package/test/hooks.test.ts +214 -0
- package/test/install-pi.test.ts +32 -0
- package/cli/src/commands/install-pi.ts +0 -5
package/package.json
CHANGED
|
@@ -18,6 +18,23 @@ export function fillTemplate(template: string, values: Record<string, string>):
|
|
|
18
18
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? '');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
|
|
22
|
+
export function readExistingPiValues(piAgentDir: string): Record<string, string> {
|
|
23
|
+
const values: Record<string, string> = {};
|
|
24
|
+
try {
|
|
25
|
+
const auth = JSON.parse(require('fs').readFileSync(path.join(piAgentDir, 'auth.json'), 'utf8'));
|
|
26
|
+
if (auth?.dashscope?.key) values['DASHSCOPE_API_KEY'] = auth.dashscope.key;
|
|
27
|
+
if (auth?.zai?.key) values['ZAI_API_KEY'] = auth.zai.key;
|
|
28
|
+
} catch { /* file doesn't exist or invalid */ }
|
|
29
|
+
try {
|
|
30
|
+
const models = JSON.parse(require('fs').readFileSync(path.join(piAgentDir, 'models.json'), 'utf8'));
|
|
31
|
+
if (!values['DASHSCOPE_API_KEY'] && models?.providers?.dashscope?.apiKey) {
|
|
32
|
+
values['DASHSCOPE_API_KEY'] = models.providers.dashscope.apiKey;
|
|
33
|
+
}
|
|
34
|
+
} catch { /* file doesn't exist or invalid */ }
|
|
35
|
+
return values;
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
function isPiInstalled(): boolean {
|
|
22
39
|
return spawnSync('pi', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
23
40
|
}
|
|
@@ -48,10 +65,15 @@ export function createInstallPiCommand(): Command {
|
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
const schema: InstallSchema = await fs.readJson(path.join(piConfigDir, 'install-schema.json'));
|
|
51
|
-
const
|
|
68
|
+
const existing = readExistingPiValues(PI_AGENT_DIR);
|
|
69
|
+
const values: Record<string, string> = { ...existing };
|
|
52
70
|
|
|
53
71
|
console.log(t.bold(' API Keys\n'));
|
|
54
72
|
for (const field of schema.fields) {
|
|
73
|
+
if (existing[field.key]) {
|
|
74
|
+
console.log(t.success(` ${sym.ok} ${field.label} [already set]`));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
55
77
|
if (!field.required && !yes) {
|
|
56
78
|
const { include } = await prompts({ type: 'confirm', name: 'include', message: ` Configure ${field.label}? (optional)`, initial: false });
|
|
57
79
|
if (!include) continue;
|
|
@@ -459,6 +459,7 @@ async function bootstrapProjectInit(): Promise<void> {
|
|
|
459
459
|
|
|
460
460
|
await runBdInitForProject(projectRoot);
|
|
461
461
|
await runGitNexusInitForProject(projectRoot);
|
|
462
|
+
await syncProjectMcpServers(projectRoot);
|
|
462
463
|
}
|
|
463
464
|
|
|
464
465
|
async function runBdInitForProject(projectRoot: string): Promise<void> {
|
package/src/commands/install.ts
CHANGED
|
@@ -125,6 +125,15 @@ function isDoltInstalled(): boolean {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function isGitnexusInstalled(): boolean {
|
|
129
|
+
try {
|
|
130
|
+
execSync('gitnexus --version', { stdio: 'ignore' });
|
|
131
|
+
return true;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
interface GlobalInstallFlags {
|
|
129
138
|
dryRun: boolean;
|
|
130
139
|
yes: boolean;
|
|
@@ -221,6 +230,34 @@ async function runGlobalInstall(
|
|
|
221
230
|
}
|
|
222
231
|
}
|
|
223
232
|
|
|
233
|
+
// Gitnexus global install (for MCP server + CLI tools)
|
|
234
|
+
console.log(t.bold('\n ⚙ gitnexus (code intelligence)'));
|
|
235
|
+
console.log(t.muted(' GitNexus provides knowledge graph queries for impact analysis, execution flows, and symbol context.'));
|
|
236
|
+
|
|
237
|
+
const gitnexusOk = isGitnexusInstalled();
|
|
238
|
+
if (gitnexusOk) {
|
|
239
|
+
console.log(t.success(' ✓ gitnexus already installed\n'));
|
|
240
|
+
} else {
|
|
241
|
+
let doInstallGitnexus = effectiveYes;
|
|
242
|
+
if (!effectiveYes) {
|
|
243
|
+
const { install } = await prompts({
|
|
244
|
+
type: 'confirm',
|
|
245
|
+
name: 'install',
|
|
246
|
+
message: 'Install gitnexus globally? (recommended for MCP server and CLI tools)',
|
|
247
|
+
initial: true,
|
|
248
|
+
});
|
|
249
|
+
doInstallGitnexus = install;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (doInstallGitnexus) {
|
|
253
|
+
console.log(t.muted('\n Installing gitnexus...'));
|
|
254
|
+
spawnSync('npm', ['install', '-g', 'gitnexus'], { stdio: 'inherit' });
|
|
255
|
+
console.log(t.success(' ✓ gitnexus installed\n'));
|
|
256
|
+
} else {
|
|
257
|
+
console.log(t.muted(' ℹ Skipped. Install later with: npm install -g gitnexus\n'));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
224
261
|
const diffTasks = new Listr<DiffCtx>(
|
|
225
262
|
targets.map(target => ({
|
|
226
263
|
title: formatTargetLabel(target),
|
package/test/hooks.test.ts
CHANGED
|
@@ -91,6 +91,7 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
91
91
|
'git pull',
|
|
92
92
|
'gh pr list',
|
|
93
93
|
'bd list',
|
|
94
|
+
`git reset --hard origin/${CURRENT_BRANCH}`,
|
|
94
95
|
];
|
|
95
96
|
for (const command of safeCommands) {
|
|
96
97
|
const r = runHook(
|
|
@@ -139,6 +140,23 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
139
140
|
const out = parseHookJson(r.stdout);
|
|
140
141
|
expect(out?.systemMessage).toContain('feature branch');
|
|
141
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
|
+
|
|
142
160
|
});
|
|
143
161
|
|
|
144
162
|
// ── main-guard-post-push.mjs ────────────────────────────────────────────────
|
|
@@ -358,6 +376,84 @@ exit 1
|
|
|
358
376
|
});
|
|
359
377
|
|
|
360
378
|
|
|
379
|
+
describe('beads-memory-gate.mjs', () => {
|
|
380
|
+
it('fails open (exit 0) when no .beads directory exists', () => {
|
|
381
|
+
const r = runHook('beads-memory-gate.mjs', { session_id: 'test', cwd: '/tmp' });
|
|
382
|
+
expect(r.status).toBe(0);
|
|
383
|
+
});
|
|
384
|
+
|
|
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
|
+
}
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
|
|
361
457
|
// ── tdd-guard-pretool-bridge.cjs ─────────────────────────────────────────────
|
|
362
458
|
|
|
363
459
|
const TDD_BRIDGE_DIR = path.join(__dirname, '../../project-skills/tdd-guard/.claude/hooks');
|
|
@@ -490,3 +586,121 @@ describe('gitnexus-impact-reminder.py', () => {
|
|
|
490
586
|
expect(r.stdout.trim()).toBe('');
|
|
491
587
|
});
|
|
492
588
|
});
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
// ── beads-gate-core.mjs — decision functions ──────────────────────────────────
|
|
592
|
+
|
|
593
|
+
describe('beads-gate-core.mjs — decision functions', () => {
|
|
594
|
+
const corePath = path.join(HOOKS_DIR, 'beads-gate-core.mjs');
|
|
595
|
+
|
|
596
|
+
it('exports all required decision functions', () => {
|
|
597
|
+
const r = spawnSync('node', ['--input-type=module'], {
|
|
598
|
+
input: `
|
|
599
|
+
import {
|
|
600
|
+
readHookInput,
|
|
601
|
+
resolveSessionContext,
|
|
602
|
+
resolveClaimAndWorkState,
|
|
603
|
+
decideEditGate,
|
|
604
|
+
decideCommitGate,
|
|
605
|
+
decideStopGate,
|
|
606
|
+
} from '${corePath}';
|
|
607
|
+
const ok = [readHookInput, resolveSessionContext, resolveClaimAndWorkState,
|
|
608
|
+
decideEditGate, decideCommitGate, decideStopGate]
|
|
609
|
+
.every(fn => typeof fn === 'function');
|
|
610
|
+
process.exit(ok ? 0 : 1);
|
|
611
|
+
`,
|
|
612
|
+
encoding: 'utf8',
|
|
613
|
+
});
|
|
614
|
+
expect(r.status).toBe(0);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe('decideEditGate', () => {
|
|
618
|
+
it('allows when not a beads project', async () => {
|
|
619
|
+
const { decideEditGate } = await import(corePath);
|
|
620
|
+
const decision = decideEditGate(
|
|
621
|
+
{ isBeadsProject: false },
|
|
622
|
+
{ claimed: false, claimId: null, totalWork: 0, inProgress: null }
|
|
623
|
+
);
|
|
624
|
+
expect(decision.allow).toBe(true);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('allows when session has a claim', async () => {
|
|
628
|
+
const { decideEditGate } = await import(corePath);
|
|
629
|
+
const decision = decideEditGate(
|
|
630
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
631
|
+
{ claimed: true, claimId: 'issue-123', totalWork: 5, inProgress: null }
|
|
632
|
+
);
|
|
633
|
+
expect(decision.allow).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('allows when no trackable work exists', async () => {
|
|
637
|
+
const { decideEditGate } = await import(corePath);
|
|
638
|
+
const decision = decideEditGate(
|
|
639
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
640
|
+
{ claimed: false, claimId: null, totalWork: 0, inProgress: null }
|
|
641
|
+
);
|
|
642
|
+
expect(decision.allow).toBe(true);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('blocks when no claim but work exists', async () => {
|
|
646
|
+
const { decideEditGate } = await import(corePath);
|
|
647
|
+
const decision = decideEditGate(
|
|
648
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
649
|
+
{ claimed: false, claimId: null, totalWork: 3, inProgress: null }
|
|
650
|
+
);
|
|
651
|
+
expect(decision.allow).toBe(false);
|
|
652
|
+
expect(decision.reason).toBe('no_claim_with_work');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('fails open when bd unavailable (state is null)', async () => {
|
|
656
|
+
const { decideEditGate } = await import(corePath);
|
|
657
|
+
const decision = decideEditGate(
|
|
658
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
659
|
+
null
|
|
660
|
+
);
|
|
661
|
+
expect(decision.allow).toBe(true);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
describe('decideCommitGate', () => {
|
|
666
|
+
it('allows when no active claim', async () => {
|
|
667
|
+
const { decideCommitGate } = await import(corePath);
|
|
668
|
+
const decision = decideCommitGate(
|
|
669
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
670
|
+
{ claimed: false, claimId: null, totalWork: 3, inProgress: { count: 1, summary: 'test' } }
|
|
671
|
+
);
|
|
672
|
+
expect(decision.allow).toBe(true);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('blocks when session has unclosed claim', async () => {
|
|
676
|
+
const { decideCommitGate } = await import(corePath);
|
|
677
|
+
const decision = decideCommitGate(
|
|
678
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
679
|
+
{ claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 1, summary: 'test' } }
|
|
680
|
+
);
|
|
681
|
+
expect(decision.allow).toBe(false);
|
|
682
|
+
expect(decision.reason).toBe('unclosed_claim');
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe('decideStopGate', () => {
|
|
687
|
+
it('allows when claim is stale (no in_progress issues)', async () => {
|
|
688
|
+
const { decideStopGate } = await import(corePath);
|
|
689
|
+
const decision = decideStopGate(
|
|
690
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
691
|
+
{ claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 0, summary: '' } }
|
|
692
|
+
);
|
|
693
|
+
expect(decision.allow).toBe(true);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('blocks when claim exists and in_progress issues remain', async () => {
|
|
697
|
+
const { decideStopGate } = await import(corePath);
|
|
698
|
+
const decision = decideStopGate(
|
|
699
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
700
|
+
{ claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 2, summary: 'test' } }
|
|
701
|
+
);
|
|
702
|
+
expect(decision.allow).toBe(false);
|
|
703
|
+
expect(decision.reason).toBe('unclosed_claim');
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
});
|
package/test/install-pi.test.ts
CHANGED
|
@@ -84,4 +84,36 @@ describe('createInstallPiCommand', () => {
|
|
|
84
84
|
expect(fs.existsSync(p.join(base, 'index.ts'))).toBe(true);
|
|
85
85
|
expect(fs.existsSync(p.join(base, 'package.json'))).toBe(true);
|
|
86
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
|
+
});
|
|
87
119
|
});
|