@zlnap/astrolabe 0.16.0 → 0.17.2

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.
@@ -0,0 +1,817 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const test = require('node:test');
8
+ const { spawnSync } = require('node:child_process');
9
+
10
+ const argsMod = require('./setup/args');
11
+ const clientsMod = require('./setup/clients');
12
+ const { CLIENTS, CATALOG_TOOL_NAMES } = clientsMod;
13
+
14
+ const BIN = path.join(__dirname, '..', 'bin', 'astrolabe.js');
15
+
16
+ function tmpHome() {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'astrolabe-setup-'));
18
+ }
19
+
20
+ test('parseArgs accepts yes/clients/dry-run/native', () => {
21
+ const opts = argsMod.parseArgs([
22
+ '--yes',
23
+ '--clients=cursor,codex',
24
+ '--dry-run',
25
+ '--native',
26
+ '--home',
27
+ '/tmp/h',
28
+ ]);
29
+ assert.equal(opts.yes, true);
30
+ assert.deepEqual(opts.clients, ['cursor', 'codex']);
31
+ assert.equal(opts.dryRun, true);
32
+ assert.equal(opts.native, true);
33
+ assert.equal(opts.home, '/tmp/h');
34
+ });
35
+
36
+ test('resolveLaunch defaults to npx @zlnap/astrolabe', () => {
37
+ assert.deepEqual(clientsMod.resolveLaunch({ context: 'cursor' }), {
38
+ command: 'npx',
39
+ args: ['-y', '@zlnap/astrolabe', '.', '--context=cursor'],
40
+ });
41
+ assert.deepEqual(clientsMod.resolveLaunch({ context: null, native: true }), {
42
+ command: 'astrolabe',
43
+ args: ['.'],
44
+ });
45
+ });
46
+
47
+ test('mergeJsonMcpFile preserves other servers', () => {
48
+ const home = tmpHome();
49
+ try {
50
+ const filePath = path.join(home, 'mcp.json');
51
+ fs.writeFileSync(
52
+ filePath,
53
+ JSON.stringify({ mcpServers: { other: { command: 'x', args: [] } } }, null, 2),
54
+ );
55
+ const entry = { command: 'npx', args: ['-y', '@zlnap/astrolabe', '.', '--context=cursor'] };
56
+ const result = clientsMod.mergeJsonMcpFile(filePath, entry);
57
+ assert.equal(result.created, false);
58
+ assert.equal(result.doc.mcpServers.other.command, 'x');
59
+ assert.deepEqual(result.doc.mcpServers.astrolabe, entry);
60
+ } finally {
61
+ fs.rmSync(home, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ test('mergeCodexToml appends and replaces astrolabe table only', () => {
66
+ const home = tmpHome();
67
+ try {
68
+ const filePath = path.join(home, 'config.toml');
69
+ fs.writeFileSync(
70
+ filePath,
71
+ 'model = "x"\n\n[mcp_servers.other]\ncommand = "o"\nargs = ["1"]\n',
72
+ );
73
+ const launch = { command: 'npx', args: ['-y', '@zlnap/astrolabe', '.', '--context=codex'] };
74
+ const first = clientsMod.mergeCodexToml(filePath, launch);
75
+ assert.match(first.text, /\[mcp_servers\.astrolabe\]/);
76
+ assert.match(first.text, /\[mcp_servers\.other\]/);
77
+ assert.match(first.text, /model = "x"/);
78
+
79
+ fs.writeFileSync(filePath, first.text);
80
+ const second = clientsMod.mergeCodexToml(filePath, {
81
+ command: 'astrolabe',
82
+ args: ['.', '--context=codex'],
83
+ });
84
+ assert.equal(second.replaced, true);
85
+ assert.match(second.text, /command = "astrolabe"/);
86
+ assert.equal((second.text.match(/\[mcp_servers\.astrolabe\]/g) || []).length, 1);
87
+ assert.match(second.text, /\[mcp_servers\.other\]/);
88
+ } finally {
89
+ fs.rmSync(home, { recursive: true, force: true });
90
+ }
91
+ });
92
+
93
+ test('CLIENTS catalog covers documented agents', () => {
94
+ const ids = CLIENTS.map((c) => c.id).sort();
95
+ assert.deepEqual(ids, [
96
+ 'claude-code',
97
+ 'cline',
98
+ 'codebuddy',
99
+ 'codex',
100
+ 'continue',
101
+ 'cursor',
102
+ 'deepseek-harness',
103
+ 'grok',
104
+ 'kimicode',
105
+ 'opencode',
106
+ 'roo',
107
+ 'windsurf',
108
+ 'zcode',
109
+ ]);
110
+ assert.equal(CATALOG_TOOL_NAMES.length, 7);
111
+ assert.equal(clientsMod.canonicalClientId('kimi'), 'kimicode');
112
+ assert.equal(clientsMod.canonicalClientId('zai'), 'zcode');
113
+ assert.equal(clientsMod.canonicalClientId('grokbuild'), 'grok');
114
+ });
115
+
116
+ test('setup --list and --dry-run via bin shim', () => {
117
+ const home = tmpHome();
118
+ try {
119
+ fs.mkdirSync(path.join(home, '.cursor'), { recursive: true });
120
+ fs.writeFileSync(
121
+ path.join(home, '.cursor', 'mcp.json'),
122
+ JSON.stringify({ mcpServers: { keep: { command: 'keep' } } }, null, 2),
123
+ );
124
+ const list = spawnSync(process.execPath, [BIN, 'setup', '--list', '--home', home], {
125
+ encoding: 'utf8',
126
+ });
127
+ assert.equal(list.status, 0, list.stderr);
128
+ assert.match(list.stdout, /cursor\s+detected/);
129
+
130
+ const dry = spawnSync(
131
+ process.execPath,
132
+ [BIN, 'setup', '--yes', '--clients=cursor', '--dry-run', '--home', home, '--no-cursor-permissions'],
133
+ { encoding: 'utf8' },
134
+ );
135
+ assert.equal(dry.status, 0, dry.stderr + dry.stdout);
136
+ assert.match(dry.stdout, /\[dry-run\]/);
137
+ const doc = JSON.parse(fs.readFileSync(path.join(home, '.cursor', 'mcp.json'), 'utf8'));
138
+ assert.equal(doc.mcpServers.keep.command, 'keep');
139
+ assert.equal(doc.mcpServers.astrolabe, undefined);
140
+ } finally {
141
+ fs.rmSync(home, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test('setup --yes writes cursor mcp.json and merges allowlist', () => {
146
+ const home = tmpHome();
147
+ try {
148
+ fs.mkdirSync(path.join(home, '.cursor'), { recursive: true });
149
+ fs.writeFileSync(
150
+ path.join(home, '.cursor', 'mcp.json'),
151
+ JSON.stringify({ mcpServers: { keep: { command: 'keep' } } }, null, 2),
152
+ );
153
+ const result = spawnSync(
154
+ process.execPath,
155
+ [BIN, 'setup', '--yes', '--clients=cursor', '--home', home],
156
+ { encoding: 'utf8' },
157
+ );
158
+ assert.equal(result.status, 0, result.stderr + result.stdout);
159
+ const mcp = JSON.parse(fs.readFileSync(path.join(home, '.cursor', 'mcp.json'), 'utf8'));
160
+ assert.equal(mcp.mcpServers.keep.command, 'keep');
161
+ assert.equal(mcp.mcpServers.astrolabe.command, 'npx');
162
+ assert.deepEqual(mcp.mcpServers.astrolabe.args, [
163
+ '-y',
164
+ '@zlnap/astrolabe',
165
+ '.',
166
+ '--context=cursor',
167
+ ]);
168
+ const perms = JSON.parse(
169
+ fs.readFileSync(path.join(home, '.cursor', 'permissions.json'), 'utf8'),
170
+ );
171
+ assert.ok(perms.mcpAllowlist.includes('astrolabe:search_code'));
172
+ assert.equal(perms.mcpAllowlist.length, 7);
173
+ } finally {
174
+ fs.rmSync(home, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ test('detect uses PATH and home markers', () => {
179
+ const home = tmpHome();
180
+ try {
181
+ fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
182
+ const env = { ...process.env, PATH: '/nonexistent/bin', HOME: home };
183
+ const codex = CLIENTS.find((c) => c.id === 'codex').detect(home, env);
184
+ assert.equal(codex.detected, true);
185
+ const windsurf = CLIENTS.find((c) => c.id === 'windsurf').detect(home, env);
186
+ assert.equal(windsurf.detected, false);
187
+ } finally {
188
+ fs.rmSync(home, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test('parseArgs accepts --with-hooks', () => {
193
+ const opts = argsMod.parseArgs(['--with-hooks', '--clients=dsh,opencode']);
194
+ assert.equal(opts.withHooks, true);
195
+ assert.deepEqual(opts.clients, ['dsh', 'opencode']);
196
+ assert.equal(clientsMod.getClient('dsh').id, 'deepseek-harness');
197
+ assert.equal(clientsMod.canonicalClientId('dsh'), 'deepseek-harness');
198
+ });
199
+
200
+ test('OpenCode V1 merge preserves JSONC comments and siblings', () => {
201
+ const home = tmpHome();
202
+ try {
203
+ const dir = path.join(home, '.config', 'opencode');
204
+ fs.mkdirSync(dir, { recursive: true });
205
+ fs.writeFileSync(
206
+ path.join(dir, 'opencode.jsonc'),
207
+ `{
208
+ // keep-comment
209
+ "mcp": {
210
+ "serena": { "type": "local", "command": ["echo"], "enabled": true }
211
+ }
212
+ }
213
+ `,
214
+ );
215
+ const r = clientsMod.mergeOpenCodeMcpFile(path.join(dir, 'opencode.jsonc'), {
216
+ command: 'npx',
217
+ args: ['-y', '@zlnap/astrolabe', '.'],
218
+ });
219
+ assert.equal(r.format, 'v1');
220
+ assert.match(r.text, /keep-comment/);
221
+ assert.match(r.text, /"serena"/);
222
+ assert.match(r.text, /"astrolabe"/);
223
+ assert.match(r.text, /"enabled"\s*:\s*true/);
224
+ } finally {
225
+ fs.rmSync(home, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test('OpenCode V2 merge writes mcp.servers without enabled', () => {
230
+ const home = tmpHome();
231
+ try {
232
+ const file = path.join(home, 'opencode.jsonc');
233
+ fs.writeFileSync(
234
+ file,
235
+ `{
236
+ "mcp": {
237
+ "servers": {
238
+ "other": { "type": "local", "command": ["x"] }
239
+ }
240
+ }
241
+ }
242
+ `,
243
+ );
244
+ const r = clientsMod.mergeOpenCodeMcpFile(file, {
245
+ command: 'npx',
246
+ args: ['-y', '@zlnap/astrolabe', '.'],
247
+ });
248
+ assert.equal(r.format, 'v2');
249
+ const doc = clientsMod.parseJsonc(r.text);
250
+ assert.ok(doc.mcp.servers.astrolabe);
251
+ assert.equal(doc.mcp.servers.astrolabe.enabled, undefined);
252
+ assert.ok(doc.mcp.servers.other);
253
+ } finally {
254
+ fs.rmSync(home, { recursive: true, force: true });
255
+ }
256
+ });
257
+
258
+ test('OpenCode resolve prefers existing ~/.opencode/config.json', () => {
259
+ const home = tmpHome();
260
+ try {
261
+ const old = path.join(home, '.opencode', 'config.json');
262
+ fs.mkdirSync(path.dirname(old), { recursive: true });
263
+ fs.writeFileSync(old, '{}\n');
264
+ assert.equal(clientsMod.resolveOpenCodeConfigPath(home, fs.existsSync), old);
265
+ } finally {
266
+ fs.rmSync(home, { recursive: true, force: true });
267
+ }
268
+ });
269
+
270
+ test('OpenCode detect uses config dir markers', () => {
271
+ const home = tmpHome();
272
+ try {
273
+ fs.mkdirSync(path.join(home, '.config', 'opencode'), { recursive: true });
274
+ const d = clientsMod.getClient('opencode').detect(home, { PATH: '/nonexistent' });
275
+ assert.equal(d.detected, true);
276
+ assert.ok(d.evidence.some((e) => e.includes('.config/opencode')));
277
+ } finally {
278
+ fs.rmSync(home, { recursive: true, force: true });
279
+ }
280
+ });
281
+
282
+ test('DSH cordis patch keeps orca-managed block', () => {
283
+ const home = tmpHome();
284
+ try {
285
+ fs.mkdirSync(path.join(home, '.dsh'), { recursive: true });
286
+ const patch = path.join(home, '.dsh', 'cordis.patch.yml');
287
+ fs.writeFileSync(
288
+ patch,
289
+ `# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>
290
+ - insert:
291
+ - id: orca-agent-hooks
292
+ name: '@deepseek-ai/dsh-hooks-claude-code'
293
+ # <<< orca-managed-dsh-hooks <<<
294
+ `,
295
+ );
296
+ const r = clientsMod.mergeDshCordisPatch(patch);
297
+ assert.match(r.text, /orca-managed-dsh-hooks/);
298
+ assert.match(r.text, /astrolabe-managed-dsh-guard/);
299
+ assert.match(r.text, /astrolabe-dsh-guard/);
300
+ } finally {
301
+ fs.rmSync(home, { recursive: true, force: true });
302
+ }
303
+ });
304
+
305
+ test('DSH plan copies vendor plugin and writes patch', () => {
306
+ const home = tmpHome();
307
+ try {
308
+ fs.mkdirSync(path.join(home, '.dsh', 'profiles', 'node_modules'), { recursive: true });
309
+ const client = clientsMod.getClient('deepseek-harness');
310
+ const plan = client.plan({
311
+ home,
312
+ launch: { command: 'npx', args: ['-y', '@zlnap/astrolabe', '.'] },
313
+ });
314
+ const applied = plan.apply();
315
+ assert.equal(applied.skipped, undefined);
316
+ applied.write();
317
+ assert.ok(
318
+ fs.existsSync(
319
+ path.join(home, '.dsh', 'profiles', 'node_modules', 'astrolabe-dsh-guard', 'package.json'),
320
+ ),
321
+ );
322
+ const text = fs.readFileSync(path.join(home, '.dsh', 'cordis.patch.yml'), 'utf8');
323
+ assert.match(text, /astrolabe-managed-dsh-guard/);
324
+ } finally {
325
+ fs.rmSync(home, { recursive: true, force: true });
326
+ }
327
+ });
328
+
329
+
330
+ test('Codebuddy detect + MCP + withHooks settings merge', () => {
331
+ const home = tmpHome();
332
+ try {
333
+ fs.mkdirSync(path.join(home, '.codebuddy'), { recursive: true });
334
+ fs.writeFileSync(
335
+ path.join(home, '.codebuddy', 'settings.json'),
336
+ JSON.stringify(
337
+ {
338
+ hooks: {
339
+ PreToolUse: [{ hooks: [{ type: 'command', command: 'echo keep' }] }],
340
+ },
341
+ },
342
+ null,
343
+ 2,
344
+ ),
345
+ );
346
+ const client = clientsMod.getClient('codebuddy');
347
+ const det = client.detect(home, { PATH: '/nonexistent' });
348
+ assert.equal(det.detected, true);
349
+ const launch = { command: 'npx', args: ['-y', '@zlnap/astrolabe', '.'] };
350
+ const plan = client.plan({ home, launch, withHooks: true });
351
+ const applied = plan.apply();
352
+ assert.equal(applied.skipped, undefined);
353
+ applied.write();
354
+ const mcp = JSON.parse(
355
+ fs.readFileSync(path.join(home, '.codebuddy', '.mcp.json'), 'utf8'),
356
+ );
357
+ assert.equal(mcp.mcpServers.astrolabe.command, 'npx');
358
+ assert.equal(mcp.mcpServers.astrolabe.type, 'stdio');
359
+ const settings = JSON.parse(
360
+ fs.readFileSync(path.join(home, '.codebuddy', 'settings.json'), 'utf8'),
361
+ );
362
+ assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, 'echo keep');
363
+ assert.ok(
364
+ settings.hooks.PreToolUse.some((e) =>
365
+ (e.hooks || []).some((h) => String(h.command).includes('--client=codebuddy')),
366
+ ),
367
+ );
368
+ assert.ok(
369
+ settings.hooks.SessionEnd.some((e) =>
370
+ (e.hooks || []).some((h) => String(h.command).includes('hooks cleanup')),
371
+ ),
372
+ );
373
+ } finally {
374
+ fs.rmSync(home, { recursive: true, force: true });
375
+ }
376
+ });
377
+
378
+ test('Grok MCP toml + withHooks astrolabe.json', () => {
379
+ const home = tmpHome();
380
+ try {
381
+ fs.mkdirSync(path.join(home, '.grok'), { recursive: true });
382
+ fs.writeFileSync(path.join(home, '.grok', 'config.toml'), 'model = "x"\n');
383
+ const client = clientsMod.getClient('grok');
384
+ assert.equal(client.detect(home, { PATH: '/nonexistent' }).detected, true);
385
+ const launch = { command: 'npx', args: ['-y', '@zlnap/astrolabe', '.'] };
386
+ const plan = client.plan({ home, launch, withHooks: true });
387
+ const applied = plan.apply();
388
+ applied.write();
389
+ const toml = fs.readFileSync(path.join(home, '.grok', 'config.toml'), 'utf8');
390
+ assert.match(toml, /model = "x"/);
391
+ assert.match(toml, /\[mcp_servers\.astrolabe\]/);
392
+ assert.match(toml, /command = "npx"/);
393
+ const hooks = JSON.parse(
394
+ fs.readFileSync(path.join(home, '.grok', 'hooks', 'astrolabe.json'), 'utf8'),
395
+ );
396
+ assert.ok(hooks.hooks.PreToolUse);
397
+ assert.ok(hooks.hooks.SessionEnd);
398
+ assert.match(
399
+ hooks.hooks.PreToolUse[0].hooks[0].command,
400
+ /--client=grok/,
401
+ );
402
+ } finally {
403
+ fs.rmSync(home, { recursive: true, force: true });
404
+ }
405
+ });
406
+
407
+ test('Kimi Code mcp.json + managed [[hooks]] toml', () => {
408
+ const home = tmpHome();
409
+ try {
410
+ fs.mkdirSync(path.join(home, '.kimi-code'), { recursive: true });
411
+ fs.writeFileSync(
412
+ path.join(home, '.kimi-code', 'mcp.json'),
413
+ JSON.stringify({ mcpServers: { keep: { command: 'x', args: [] } } }, null, 2),
414
+ );
415
+ fs.writeFileSync(
416
+ path.join(home, '.kimi-code', 'config.toml'),
417
+ 'default_model = "kimi"\n\n[[hooks]]\nevent = "Notification"\ncommand = "echo keep"\n',
418
+ );
419
+ const client = clientsMod.getClient('kimi');
420
+ assert.equal(client.id, 'kimicode');
421
+ const launch = { command: 'npx', args: ['-y', '@zlnap/astrolabe', '.'] };
422
+ const plan = client.plan({ home, launch, withHooks: true });
423
+ plan.apply().write();
424
+ const mcp = JSON.parse(fs.readFileSync(path.join(home, '.kimi-code', 'mcp.json'), 'utf8'));
425
+ assert.equal(mcp.mcpServers.keep.command, 'x');
426
+ assert.equal(mcp.mcpServers.astrolabe.command, 'npx');
427
+ const toml = fs.readFileSync(path.join(home, '.kimi-code', 'config.toml'), 'utf8');
428
+ assert.match(toml, /default_model = "kimi"/);
429
+ assert.match(toml, /echo keep/);
430
+ assert.match(toml, /astrolabe-managed-kimi-hooks/);
431
+ assert.match(toml, /--client=kimicode/);
432
+ assert.match(toml, /event = "SessionEnd"/);
433
+ // idempotent
434
+ const again = clientsMod.mergeKimiHooksToml(path.join(home, '.kimi-code', 'config.toml'));
435
+ assert.equal(again.replaced, true);
436
+ assert.equal((again.text.match(/astrolabe-managed-kimi-hooks/g) || []).length, 2);
437
+ } finally {
438
+ fs.rmSync(home, { recursive: true, force: true });
439
+ }
440
+ });
441
+
442
+ test('ZCode mcp.servers + hooks.events without SessionEnd', () => {
443
+ const home = tmpHome();
444
+ try {
445
+ const cli = path.join(home, '.zcode', 'cli');
446
+ fs.mkdirSync(cli, { recursive: true });
447
+ fs.writeFileSync(
448
+ path.join(cli, 'config.json'),
449
+ JSON.stringify(
450
+ {
451
+ mcp: { servers: { keep: { type: 'stdio', command: 'x', args: [] } } },
452
+ hooks: {
453
+ enabled: false,
454
+ events: {
455
+ SessionEnd: [{ hooks: [{ type: 'command', command: 'bad' }] }],
456
+ // Misconfigured: cleanup on Stop would wipe counters every turn
457
+ Stop: [
458
+ {
459
+ hooks: [
460
+ { type: 'command', command: 'astrolabe hooks cleanup' },
461
+ { type: 'command', command: 'echo keep-stop' },
462
+ ],
463
+ },
464
+ ],
465
+ PreToolUse: [{ hooks: [{ type: 'command', command: 'echo keep' }] }],
466
+ },
467
+ },
468
+ },
469
+ null,
470
+ 2,
471
+ ),
472
+ );
473
+ const client = clientsMod.getClient('zai');
474
+ assert.equal(client.id, 'zcode');
475
+ const launch = { command: 'npx', args: ['-y', '@zlnap/astrolabe', '.'] };
476
+ const plan = client.plan({ home, launch, withHooks: true });
477
+ plan.apply().write();
478
+ const doc = JSON.parse(fs.readFileSync(path.join(cli, 'config.json'), 'utf8'));
479
+ assert.equal(doc.mcp.servers.keep.command, 'x');
480
+ assert.equal(doc.mcp.servers.astrolabe.type, 'stdio');
481
+ assert.equal(doc.mcp.servers.astrolabe.command, 'npx');
482
+ assert.equal(doc.hooks.enabled, true);
483
+ assert.equal(doc.hooks.events.SessionEnd, undefined);
484
+ assert.ok(
485
+ !JSON.stringify(doc.hooks).includes('SessionEnd'),
486
+ 'SessionEnd must not appear in ZCode hooks',
487
+ );
488
+ // Stop keeps non-cleanup hooks; cleanup command is stripped
489
+ assert.ok(Array.isArray(doc.hooks.events.Stop));
490
+ assert.equal(doc.hooks.events.Stop.length, 1);
491
+ assert.equal(doc.hooks.events.Stop[0].hooks.length, 1);
492
+ assert.equal(doc.hooks.events.Stop[0].hooks[0].command, 'echo keep-stop');
493
+ assert.ok(
494
+ !JSON.stringify(doc.hooks).includes('hooks cleanup'),
495
+ 'Stop must not hang astrolabe hooks cleanup',
496
+ );
497
+ assert.ok(
498
+ doc.hooks.events.PreToolUse.some((e) =>
499
+ (e.hooks || []).some((h) => String(h.command).includes('--client=zcode')),
500
+ ),
501
+ );
502
+ assert.ok(
503
+ doc.hooks.events.PreToolUse.some((e) =>
504
+ (e.hooks || []).some((h) => h.command === 'echo keep'),
505
+ ),
506
+ );
507
+ // MCP-only path does not force hooks.enabled
508
+ const home2 = tmpHome();
509
+ try {
510
+ fs.mkdirSync(path.join(home2, '.zcode', 'cli'), { recursive: true });
511
+ const p2 = clientsMod.getClient('zcode').plan({
512
+ home: home2,
513
+ launch,
514
+ withHooks: false,
515
+ });
516
+ p2.apply().write();
517
+ const d2 = JSON.parse(
518
+ fs.readFileSync(path.join(home2, '.zcode', 'cli', 'config.json'), 'utf8'),
519
+ );
520
+ assert.ok(d2.mcp.servers.astrolabe);
521
+ assert.equal(d2.hooks, undefined);
522
+ } finally {
523
+ fs.rmSync(home2, { recursive: true, force: true });
524
+ }
525
+ } finally {
526
+ fs.rmSync(home, { recursive: true, force: true });
527
+ }
528
+ });
529
+
530
+ test('ZCode withHooks strips Stop cleanup but keeps non-cleanup Stop', () => {
531
+ const home = tmpHome();
532
+ try {
533
+ const cli = path.join(home, '.zcode', 'cli');
534
+ fs.mkdirSync(cli, { recursive: true });
535
+ fs.writeFileSync(
536
+ path.join(cli, 'config.json'),
537
+ JSON.stringify(
538
+ {
539
+ hooks: {
540
+ enabled: true,
541
+ events: {
542
+ Stop: [
543
+ {
544
+ matcher: 'cleanup-only',
545
+ hooks: [{ type: 'command', command: 'astrolabe hooks cleanup' }],
546
+ },
547
+ {
548
+ matcher: 'other',
549
+ hooks: [{ type: 'command', command: 'echo turn-end' }],
550
+ },
551
+ ],
552
+ },
553
+ },
554
+ },
555
+ null,
556
+ 2,
557
+ ),
558
+ );
559
+ const launch = { command: 'npx', args: ['-y', '@zlnap/astrolabe', '.'] };
560
+ clientsMod
561
+ .getClient('zcode')
562
+ .plan({ home, launch, withHooks: true })
563
+ .apply()
564
+ .write();
565
+ const doc = JSON.parse(fs.readFileSync(path.join(cli, 'config.json'), 'utf8'));
566
+ assert.equal(doc.hooks.events.SessionEnd, undefined);
567
+ assert.ok(Array.isArray(doc.hooks.events.Stop));
568
+ assert.equal(doc.hooks.events.Stop.length, 1);
569
+ assert.equal(doc.hooks.events.Stop[0].matcher, 'other');
570
+ assert.equal(doc.hooks.events.Stop[0].hooks[0].command, 'echo turn-end');
571
+ assert.ok(!JSON.stringify(doc.hooks).includes('hooks cleanup'));
572
+ } finally {
573
+ fs.rmSync(home, { recursive: true, force: true });
574
+ }
575
+ });
576
+
577
+ test('setup --clients aliases resolve for new clients', () => {
578
+ const home = tmpHome();
579
+ try {
580
+ fs.mkdirSync(path.join(home, '.grok'), { recursive: true });
581
+ const dry = spawnSync(
582
+ process.execPath,
583
+ [
584
+ BIN,
585
+ 'setup',
586
+ '--yes',
587
+ '--clients=grokbuild,kimi,zai,codebuddy',
588
+ '--dry-run',
589
+ '--home',
590
+ home,
591
+ '--no-cursor-permissions',
592
+ ],
593
+ { encoding: 'utf8' },
594
+ );
595
+ assert.equal(dry.status, 0, dry.stderr + dry.stdout);
596
+ assert.match(dry.stdout, /Grok Build/);
597
+ assert.match(dry.stdout, /Kimi Code/);
598
+ assert.match(dry.stdout, /ZCode/);
599
+ // codebuddy skipped (no ~/.codebuddy)
600
+ assert.match(dry.stdout, /Codebuddy: SKIP|Codebuddy/);
601
+ } finally {
602
+ fs.rmSync(home, { recursive: true, force: true });
603
+ }
604
+ });
605
+
606
+ test('Cursor client context is always cursor (never claude-code)', () => {
607
+ const client = clientsMod.getClient('cursor');
608
+ assert.equal(client.context, 'cursor');
609
+ const launch = clientsMod.resolveLaunch({ context: client.context });
610
+ assert.ok(launch.args.includes('--context=cursor'));
611
+ assert.ok(!launch.args.some((a) => String(a).includes('claude-code')));
612
+ });
613
+
614
+ test('Cursor --with-hooks writes hooks.json with --client=cursor and mcp matcher', () => {
615
+ const home = tmpHome();
616
+ try {
617
+ fs.mkdirSync(path.join(home, '.cursor'), { recursive: true });
618
+ // Bad prior config: wrong client + matcher missing mcp__astrolabe__.*
619
+ fs.writeFileSync(
620
+ path.join(home, '.cursor', 'hooks.json'),
621
+ JSON.stringify(
622
+ {
623
+ version: 1,
624
+ hooks: {
625
+ preToolUse: [
626
+ {
627
+ command: 'astrolabe hooks remind --client=claude-code',
628
+ matcher: 'Shell|Read|Grep',
629
+ },
630
+ ],
631
+ },
632
+ },
633
+ null,
634
+ 2,
635
+ ),
636
+ );
637
+ fs.writeFileSync(
638
+ path.join(home, '.cursor', 'mcp.json'),
639
+ JSON.stringify(
640
+ {
641
+ mcpServers: {
642
+ astrolabe: {
643
+ command: 'npx',
644
+ args: ['-y', '@zlnap/astrolabe', '.', '--context=claude-code'],
645
+ },
646
+ },
647
+ },
648
+ null,
649
+ 2,
650
+ ),
651
+ );
652
+ const result = spawnSync(
653
+ process.execPath,
654
+ [
655
+ BIN,
656
+ 'setup',
657
+ '--yes',
658
+ '--clients=cursor',
659
+ '--with-hooks',
660
+ '--home',
661
+ home,
662
+ '--no-cursor-permissions',
663
+ ],
664
+ { encoding: 'utf8' },
665
+ );
666
+ assert.equal(result.status, 0, result.stderr + result.stdout);
667
+
668
+ const mcp = JSON.parse(fs.readFileSync(path.join(home, '.cursor', 'mcp.json'), 'utf8'));
669
+ assert.deepEqual(mcp.mcpServers.astrolabe.args, [
670
+ '-y',
671
+ '@zlnap/astrolabe',
672
+ '.',
673
+ '--context=cursor',
674
+ ]);
675
+ assert.ok(!JSON.stringify(mcp).includes('claude-code'));
676
+
677
+ const hooks = JSON.parse(fs.readFileSync(path.join(home, '.cursor', 'hooks.json'), 'utf8'));
678
+ assert.equal(hooks.version, 1);
679
+ const remind = hooks.hooks.preToolUse.find((e) =>
680
+ String(e.command || '').includes('astrolabe hooks remind'),
681
+ );
682
+ assert.ok(remind, 'preToolUse remind missing');
683
+ assert.equal(remind.command, 'astrolabe hooks remind --client=cursor');
684
+ assert.equal(remind.matcher, clientsMod.CURSOR_REMIND_MATCHER);
685
+ assert.match(remind.matcher, /mcp__astrolabe__\.\*/);
686
+ assert.ok(!remind.command.includes('claude-code'));
687
+ const cleanup = hooks.hooks.sessionEnd.find((e) =>
688
+ String(e.command || '').includes('hooks cleanup'),
689
+ );
690
+ assert.ok(cleanup, 'sessionEnd cleanup missing');
691
+ // Idempotent: no duplicate reminds
692
+ const remindCount = hooks.hooks.preToolUse.filter((e) =>
693
+ String(e.command || '').includes('astrolabe hooks remind'),
694
+ ).length;
695
+ assert.equal(remindCount, 1);
696
+ } finally {
697
+ fs.rmSync(home, { recursive: true, force: true });
698
+ }
699
+ });
700
+
701
+ test('ZCode --with-hooks replaces wrong remind client and scrubs Stop cleanup', () => {
702
+ const home = tmpHome();
703
+ try {
704
+ const cli = path.join(home, '.zcode', 'cli');
705
+ fs.mkdirSync(cli, { recursive: true });
706
+ fs.writeFileSync(
707
+ path.join(cli, 'config.json'),
708
+ JSON.stringify(
709
+ {
710
+ mcp: { servers: {} },
711
+ hooks: {
712
+ enabled: true,
713
+ events: {
714
+ PreToolUse: [
715
+ {
716
+ matcher: '*',
717
+ hooks: [
718
+ {
719
+ type: 'command',
720
+ command: 'astrolabe hooks remind --client=claude-code',
721
+ timeout: 10,
722
+ },
723
+ ],
724
+ },
725
+ ],
726
+ Stop: [
727
+ {
728
+ matcher: '*',
729
+ hooks: [
730
+ {
731
+ type: 'command',
732
+ command: 'astrolabe hooks cleanup',
733
+ timeout: 10,
734
+ },
735
+ {
736
+ type: 'command',
737
+ command: 'echo keep-stop',
738
+ },
739
+ ],
740
+ },
741
+ ],
742
+ SessionEnd: [
743
+ {
744
+ hooks: [{ type: 'command', command: 'astrolabe hooks cleanup' }],
745
+ },
746
+ ],
747
+ },
748
+ },
749
+ },
750
+ null,
751
+ 2,
752
+ ),
753
+ );
754
+ const client = clientsMod.getClient('zcode');
755
+ assert.equal(client.context, null);
756
+ const launch = clientsMod.resolveLaunch({ context: client.context });
757
+ assert.ok(!launch.args.some((a) => String(a).includes('--context=')));
758
+ const plan = client.plan({ home, launch, withHooks: true });
759
+ plan.apply().write();
760
+ const doc = JSON.parse(fs.readFileSync(path.join(cli, 'config.json'), 'utf8'));
761
+ assert.equal(doc.hooks.events.SessionEnd, undefined);
762
+ assert.ok(!JSON.stringify(doc.hooks).includes('SessionEnd'));
763
+ // Stop cleanup removed; non-cleanup Stop hook preserved
764
+ assert.ok(doc.hooks.events.Stop, 'Stop should remain when non-cleanup hooks survive');
765
+ const stopCmds = (doc.hooks.events.Stop || []).flatMap((e) =>
766
+ (e.hooks || []).map((h) => h.command),
767
+ );
768
+ assert.ok(stopCmds.includes('echo keep-stop'));
769
+ assert.ok(!stopCmds.some((c) => String(c).includes('hooks cleanup')));
770
+ // Wrong client replaced
771
+ const remindCmds = (doc.hooks.events.PreToolUse || []).flatMap((e) =>
772
+ (e.hooks || []).map((h) => h.command),
773
+ );
774
+ assert.ok(remindCmds.includes('astrolabe hooks remind --client=zcode'));
775
+ assert.ok(!remindCmds.some((c) => String(c).includes('--client=claude-code')));
776
+ // Pure Stop-cleanup-only configs drop Stop entirely
777
+ const home2 = tmpHome();
778
+ try {
779
+ const cli2 = path.join(home2, '.zcode', 'cli');
780
+ fs.mkdirSync(cli2, { recursive: true });
781
+ fs.writeFileSync(
782
+ path.join(cli2, 'config.json'),
783
+ JSON.stringify(
784
+ {
785
+ hooks: {
786
+ events: {
787
+ Stop: [
788
+ {
789
+ hooks: [{ type: 'command', command: 'astrolabe hooks cleanup' }],
790
+ },
791
+ ],
792
+ },
793
+ },
794
+ },
795
+ null,
796
+ 2,
797
+ ),
798
+ );
799
+ clientsMod
800
+ .getClient('zcode')
801
+ .plan({ home: home2, launch, withHooks: true })
802
+ .apply()
803
+ .write();
804
+ const d2 = JSON.parse(fs.readFileSync(path.join(cli2, 'config.json'), 'utf8'));
805
+ assert.equal(d2.hooks.events.Stop, undefined);
806
+ assert.ok(
807
+ d2.hooks.events.PreToolUse.some((e) =>
808
+ (e.hooks || []).some((h) => h.command === 'astrolabe hooks remind --client=zcode'),
809
+ ),
810
+ );
811
+ } finally {
812
+ fs.rmSync(home2, { recursive: true, force: true });
813
+ }
814
+ } finally {
815
+ fs.rmSync(home, { recursive: true, force: true });
816
+ }
817
+ });