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.
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/extensions/beads.ts +12 -9
- package/package.json +1 -1
- package/src/commands/install-pi.ts +12 -0
- package/src/commands/install-project.ts +1 -1
- package/test/install-pi.test.ts +39 -1
- package/test/install-project.test.ts +29 -26
- package/vitest.config.ts +1 -0
package/extensions/beads.ts
CHANGED
|
@@ -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
|
-
//
|
|
14
|
-
const
|
|
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 (
|
|
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
|
|
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
|
@@ -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
|
}
|
package/test/install-pi.test.ts
CHANGED
|
@@ -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','
|
|
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/
|
|
123
|
+
path.join(__dirname, '../../project-skills/quality-gates/README.md'),
|
|
124
124
|
'utf8',
|
|
125
125
|
);
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
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('
|
|
157
|
+
await installProjectSkill('tdd-guard', tmpDir);
|
|
158
158
|
|
|
159
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', '
|
|
160
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', '
|
|
161
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', '
|
|
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('
|
|
190
|
+
await installProjectSkill('tdd-guard', tmpDir);
|
|
192
191
|
|
|
193
|
-
const
|
|
192
|
+
const guardRun = spawnSync(
|
|
194
193
|
'node',
|
|
195
|
-
[path.join(tmpDir, '.claude', 'hooks', '
|
|
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(
|
|
201
|
+
expect(guardRun.status).toBe(0);
|
|
203
202
|
|
|
204
203
|
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
205
|
-
const
|
|
206
|
-
expect(
|
|
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
|
-
'
|
|
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.
|
|
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
|
});
|