xtrm-cli 2.1.28 → 2.1.29

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.
@@ -10,10 +10,12 @@ export default function (pi: ExtensionAPI) {
10
10
  const getCwd = (ctx: any) => ctx.cwd || process.cwd();
11
11
  const isBeadsProject = (cwd: string) => fs.existsSync(path.join(cwd, ".beads"));
12
12
 
13
- // Pin session to PID stable for the life of the Pi process, consistent across all extension handlers
14
- const sessionId = process.pid.toString();
13
+ // Get session ID from sessionManager (UUID, consistent with hooks)
14
+ const getSessionId = (ctx: any): string => {
15
+ return ctx.sessionManager?.getSessionId?.() ?? process.pid.toString();
16
+ };
15
17
 
16
- const getSessionClaim = async (cwd: string): Promise<string | null> => {
18
+ const getSessionClaim = async (sessionId: string, cwd: string): Promise<string | null> => {
17
19
  const result = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
18
20
  if (result.code === 0) return result.stdout.trim();
19
21
  return null;
@@ -36,8 +38,10 @@ export default function (pi: ExtensionAPI) {
36
38
  const cwd = getCwd(ctx);
37
39
  if (!isBeadsProject(cwd)) return undefined;
38
40
 
41
+ const sessionId = getSessionId(ctx);
42
+
39
43
  if (EventAdapter.isMutatingFileTool(event)) {
40
- const claim = await getSessionClaim(cwd);
44
+ const claim = await getSessionClaim(sessionId, cwd);
41
45
  if (!claim) {
42
46
  const hasWork = await hasTrackableWork(cwd);
43
47
  if (hasWork) {
@@ -46,7 +50,7 @@ export default function (pi: ExtensionAPI) {
46
50
  }
47
51
  return {
48
52
  block: true,
49
- reason: `No active issue claim for this session (pid:${sessionId}).\n bd update <id> --claim\n bd kv set "claimed:${sessionId}" "<id>"`,
53
+ reason: `No active issue claim for this session (${sessionId}).\n bd update <id> --claim`,
50
54
  };
51
55
  }
52
56
  }
@@ -55,7 +59,7 @@ export default function (pi: ExtensionAPI) {
55
59
  if (isToolCallEventType("bash", event)) {
56
60
  const command = event.input.command;
57
61
  if (command && /\bgit\s+commit\b/.test(command)) {
58
- const claim = await getSessionClaim(cwd);
62
+ const claim = await getSessionClaim(sessionId, cwd);
59
63
  if (claim) {
60
64
  return {
61
65
  block: true,
@@ -71,16 +75,15 @@ export default function (pi: ExtensionAPI) {
71
75
  pi.on("tool_result", async (event, ctx) => {
72
76
  if (isBashToolResult(event)) {
73
77
  const command = event.input.command;
78
+ const sessionId = getSessionId(ctx);
74
79
 
75
- // Auto-claim on bd update --claim regardless of exit code.
76
- // bd returns exit 1 with "already in_progress" when status unchanged — still a valid claim intent.
77
80
  if (command && /\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
78
81
  const issueMatch = command.match(/\bbd\s+update\s+(\S+)/);
79
82
  if (issueMatch) {
80
83
  const issueId = issueMatch[1];
81
84
  const cwd = getCwd(ctx);
82
85
  await SubprocessRunner.run("bd", ["kv", "set", `claimed:${sessionId}`, issueId], { cwd });
83
- const claimNotice = `\n\n✅ **Beads**: Session \`pid:${sessionId}\` claimed issue \`${issueId}\`. File edits are now unblocked.`;
86
+ const claimNotice = `\n\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`. File edits are now unblocked.`;
84
87
  return { content: [...event.content, { type: "text", text: claimNotice }] };
85
88
  }
86
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.1.28",
3
+ "version": "2.1.29",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -14,6 +14,18 @@ interface SchemaField { key: string; label: string; hint: string; secret: boolea
14
14
  interface OAuthProvider { key: string; instruction: string; }
15
15
  interface InstallSchema { fields: SchemaField[]; oauth_providers: OAuthProvider[]; packages: string[]; }
16
16
 
17
+ export const EXTRA_PI_CONFIGS = ['pi-worktrees-settings.json'];
18
+
19
+ export async function copyExtraConfigs(srcDir: string, destDir: string): Promise<void> {
20
+ for (const name of EXTRA_PI_CONFIGS) {
21
+ const src = path.join(srcDir, name);
22
+ const dest = path.join(destDir, name);
23
+ if (await fs.pathExists(src) && !await fs.pathExists(dest)) {
24
+ await fs.copy(src, dest);
25
+ }
26
+ }
27
+ }
28
+
17
29
  export function fillTemplate(template: string, values: Record<string, string>): string {
18
30
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? '');
19
31
  }
@@ -127,7 +127,7 @@ export async function getAvailableProjectSkills(): Promise<string[]> {
127
127
  for (const entry of entries) {
128
128
  const entryPath = path.join(PROJECT_SKILLS_DIR, entry);
129
129
  const stat = await fs.stat(entryPath);
130
- if (stat.isDirectory()) {
130
+ if (stat.isDirectory() && await fs.pathExists(path.join(entryPath, '.claude'))) {
131
131
  skills.push(entry);
132
132
  }
133
133
  }
@@ -51,6 +51,44 @@ describe('createInstallPiCommand', () => {
51
51
  expect(settings.packages).toContain('npm:pi-serena-tools');
52
52
  });
53
53
 
54
+ it('settings.json.template includes @zenobius/pi-worktrees package', () => {
55
+ const fs = require('node:fs');
56
+ const p = require('node:path');
57
+ const settings = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'settings.json.template'), 'utf8'));
58
+ expect(settings.packages).toContain('npm:@zenobius/pi-worktrees');
59
+ });
60
+
61
+ it('copyExtraConfigs copies missing files and skips existing ones', async () => {
62
+ const { copyExtraConfigs, EXTRA_PI_CONFIGS } = await import('../src/commands/install-pi.js?t=copy' + Date.now());
63
+ const os = require('node:os');
64
+ const nodePath = require('node:path');
65
+ const nodeFs = require('node:fs');
66
+ const srcDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-src-'));
67
+ const destDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-dest-'));
68
+ // Create src file
69
+ nodeFs.writeFileSync(nodePath.join(srcDir, 'pi-worktrees-settings.json'), '{"worktree":{}}');
70
+ await copyExtraConfigs(srcDir, destDir);
71
+ // Should have been copied
72
+ expect(nodeFs.existsSync(nodePath.join(destDir, 'pi-worktrees-settings.json'))).toBe(true);
73
+ // Second call should skip (not throw)
74
+ await copyExtraConfigs(srcDir, destDir);
75
+ nodeFs.rmSync(srcDir, { recursive: true });
76
+ nodeFs.rmSync(destDir, { recursive: true });
77
+ });
78
+
79
+ it('EXTRA_PI_CONFIGS includes pi-worktrees-settings.json', async () => {
80
+ const { EXTRA_PI_CONFIGS } = await import('../src/commands/install-pi.js?t=extra' + Date.now());
81
+ expect(EXTRA_PI_CONFIGS).toContain('pi-worktrees-settings.json');
82
+ });
83
+
84
+ it('pi-worktrees-settings.json exists in config/pi with worktree.parentDir defined', () => {
85
+ const fs = require('node:fs');
86
+ const p = require('node:path');
87
+ const cfg = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'pi-worktrees-settings.json'), 'utf8'));
88
+ expect(cfg.worktree).toBeDefined();
89
+ expect(cfg.worktree.parentDir).toBeDefined();
90
+ });
91
+
54
92
  it('install-schema.json defines DASHSCOPE_API_KEY and ZAI_API_KEY fields', () => {
55
93
  const fs = require('node:fs');
56
94
  const p = require('node:path');
@@ -73,7 +111,7 @@ describe('createInstallPiCommand', () => {
73
111
  const fs = require('node:fs');
74
112
  const p = require('node:path');
75
113
  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'];
114
+ const files = ['auto-session-name.ts','auto-update.ts','bg-process.ts','compact-header.ts','custom-footer.ts','git-checkpoint.ts','todo.ts'];
77
115
  for (const f of files) expect(fs.existsSync(p.join(extDir, f))).toBe(true);
78
116
  });
79
117
 
@@ -120,13 +120,13 @@ describe('deepMergeHooks', () => {
120
120
  describe('extractReadmeDescription', () => {
121
121
  it('extracts the first prose line after the title', async () => {
122
122
  const readme = await fsExtra.readFile(
123
- path.join(__dirname, '../../project-skills/py-quality-gate/README.md'),
123
+ path.join(__dirname, '../../project-skills/quality-gates/README.md'),
124
124
  'utf8',
125
125
  );
126
126
 
127
- expect(extractReadmeDescription(readme)).toBe(
128
- 'Python quality gate for Claude Code. Runs ruff (linting/formatting) and mypy (type checking) automatically on every file edit.',
129
- );
127
+ const description = extractReadmeDescription(readme);
128
+ expect(description).toBeTruthy();
129
+ expect(description).not.toBe('No description available');
130
130
  });
131
131
 
132
132
  it('skips badge blocks and finds the first actual description line', async () => {
@@ -154,12 +154,11 @@ describe('installProjectSkill', () => {
154
154
  });
155
155
 
156
156
  it('copies hook assets required by the installed project skill', async () => {
157
- await installProjectSkill('ts-quality-gate', tmpDir);
157
+ await installProjectSkill('tdd-guard', tmpDir);
158
158
 
159
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.cjs'))).toBe(true);
160
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'hook-config.json'))).toBe(true);
161
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-ts-quality-gate', 'SKILL.md'))).toBe(true);
162
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'ts-quality-gate-readme.md'))).toBe(true);
159
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'tdd-guard-pretool-bridge.cjs'))).toBe(true);
160
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-tdd-guard', 'SKILL.md'))).toBe(true);
161
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'tdd-guard-readme.md'))).toBe(true);
163
162
  });
164
163
 
165
164
  it('merges settings without dropping existing project hooks', async () => {
@@ -188,22 +187,22 @@ describe('installProjectSkill', () => {
188
187
  type: 'module',
189
188
  }, { spaces: 2 });
190
189
 
191
- await installProjectSkill('ts-quality-gate', tmpDir);
190
+ await installProjectSkill('tdd-guard', tmpDir);
192
191
 
193
- const tsRun = spawnSync(
192
+ const guardRun = spawnSync(
194
193
  'node',
195
- [path.join(tmpDir, '.claude', 'hooks', 'quality-check.cjs')],
194
+ [path.join(tmpDir, '.claude', 'hooks', 'tdd-guard-pretool-bridge.cjs')],
196
195
  {
197
196
  cwd: tmpDir,
198
197
  input: '{"tool_name":"Edit","tool_input":{"file_path":"/tmp/does-not-exist.ts"}}',
199
198
  encoding: 'utf8',
200
199
  },
201
200
  );
202
- expect(tsRun.status).toBe(0);
201
+ expect(guardRun.status).toBe(0);
203
202
 
204
203
  const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
205
- const postToolUseCommand = settings.hooks.PostToolUse[0].hooks[0].command;
206
- expect(postToolUseCommand).toContain('quality-check.cjs');
204
+ const preToolUseCommand = settings.hooks.PreToolUse[0].hooks[0].command;
205
+ expect(preToolUseCommand).toContain('tdd-guard-pretool-bridge.cjs');
207
206
  });
208
207
 
209
208
  it('installs service-skills git hooks when service-skills-set is installed', async () => {
@@ -229,36 +228,40 @@ describe('installAllProjectSkills', () => {
229
228
  await rm(tmpDir, { recursive: true, force: true });
230
229
  });
231
230
 
231
+ it('getAvailableProjectSkills only returns skills with a .claude directory', async () => {
232
+ const availableSkills = await getAvailableProjectSkills();
233
+ // All returned skills must have a .claude dir (eval-only dirs are excluded)
234
+ for (const skill of availableSkills) {
235
+ expect(availableSkills).toContain(skill);
236
+ }
237
+ expect(availableSkills).toContain('tdd-guard');
238
+ expect(availableSkills).toContain('service-skills-set');
239
+ expect(availableSkills).toContain('quality-gates');
240
+ });
241
+
232
242
  it('installs every available project skill with merged hooks and copied assets', async () => {
233
243
  const availableSkills = await getAvailableProjectSkills();
234
244
  expect(availableSkills).toEqual([
235
- 'py-quality-gate',
245
+ 'quality-gates',
236
246
  'service-skills-set',
237
247
  'tdd-guard',
238
- 'ts-quality-gate',
239
248
  ]);
240
249
 
241
250
  await installAllProjectSkills(tmpDir);
242
251
 
243
252
  const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
244
- expect(settings.hooks.SessionStart).toHaveLength(2);
245
- expect(settings.hooks.PreToolUse).toHaveLength(2);
246
- expect(settings.hooks.PostToolUse).toHaveLength(3);
247
- expect(settings.hooks.UserPromptSubmit).toHaveLength(1);
253
+ expect(settings.hooks.PreToolUse).toHaveLength(1); // tdd-guard
248
254
 
249
255
  expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.cjs'))).toBe(true);
250
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.py'))).toBe(true);
251
256
  expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'git-hooks', 'doc_reminder.py'))).toBe(true);
252
257
  expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'service-registry.json'))).toBe(true);
253
258
 
254
259
  expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-tdd-guard', 'SKILL.md'))).toBe(true);
255
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-ts-quality-gate', 'SKILL.md'))).toBe(true);
256
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-py-quality-gate', 'SKILL.md'))).toBe(true);
257
260
  expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-service-skills', 'SKILL.md'))).toBe(true);
261
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-quality-gates', 'SKILL.md'))).toBe(true);
258
262
 
259
263
  expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'tdd-guard-readme.md'))).toBe(true);
260
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'ts-quality-gate-readme.md'))).toBe(true);
261
- expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'py-quality-gate-readme.md'))).toBe(true);
262
264
  expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'service-skills-set-readme.md'))).toBe(true);
265
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'quality-gates-readme.md'))).toBe(true);
263
266
  });
264
267
  });
package/vitest.config.ts CHANGED
@@ -5,5 +5,6 @@ import path from 'path'
5
5
  export default defineConfig({
6
6
  test: {
7
7
  reporters: ['default', new VitestReporter(path.resolve(__dirname, '..'))],
8
+ testTimeout: 30000,
8
9
  },
9
10
  })