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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.1.11",
3
+ "version": "2.1.16",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -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 values: Record<string, string> = {};
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> {
@@ -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),
@@ -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
+ });
@@ -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
  });
@@ -1,5 +0,0 @@
1
- import { Command } from 'commander';
2
-
3
- export function createInstallPiCommand(): Command {
4
- return new Command('pi');
5
- }