xtrm-cli 2.1.11 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.1.11",
3
+ "version": "2.1.14",
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> {
@@ -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');
@@ -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
- }