@zlnap/astrolabe 0.17.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.
package/README.md CHANGED
@@ -12,7 +12,7 @@ MCP 客户端(推荐一键配置):
12
12
  npx -y @zlnap/astrolabe setup
13
13
  ```
14
14
 
15
- Detects Claude Code, Cursor, Codex, Windsurf, Cline, Roo, Continue, OpenCode, DeepSeek Harness, Codebuddy, Grok Build, Kimi Code, and ZCode. Merges MCP config (OpenCode V1/V2/jsonc; Grok `[mcp_servers.*]` TOML; ZCode `mcp.servers`; DSH installs the native guard plugin + `cordis.patch.yml` merge). Optional `--with-hooks` installs OpenCode's guard plugin and hook recipes for Codebuddy / Grok / Kimi / ZCode (ZCode: no `SessionEnd`, do not hang cleanup on `Stop`). Flags: `--yes`, `--clients=…`, `--dry-run`, `--with-hooks`, `--list`.
15
+ Detects Claude Code, Cursor, Codex, Windsurf, Cline, Roo, Continue, OpenCode, DeepSeek Harness, Codebuddy, Grok Build, Kimi Code, and ZCode. Merges MCP config (OpenCode V1/V2/jsonc; Grok `[mcp_servers.*]` TOML; ZCode `mcp.servers`; DSH installs the native guard plugin + `cordis.patch.yml` merge). Optional `--with-hooks` installs OpenCode's guard plugin and hook recipes for Cursor / Codebuddy / Grok / Kimi / ZCode (Cursor: `~/.cursor/hooks.json` with `--client=cursor`; ZCode: no `SessionEnd`, scrub cleanup off `Stop`). Flags: `--yes`, `--clients=…`, `--dry-run`, `--with-hooks`, `--list`.
16
16
 
17
17
  探测本机 Claude Code / Cursor / Codex 等,交互勾选后合并写入 MCP 配置(`--yes`、`--dry-run`、`--list` 可用)。手动配置示例:
18
18
 
package/lib/setup/args.js CHANGED
@@ -13,8 +13,10 @@ Options:
13
13
  --native Use command "astrolabe" on PATH instead of npx
14
14
  --command=<bin> Override MCP command (default: npx, or astrolabe with --native)
15
15
  --with-hooks Also install drift-guard hooks/plugins where supported
16
- (OpenCode guard plugin; Codebuddy/Grok/Kimi/ZCode hook recipes;
17
- DSH always installs its native guard)
16
+ (OpenCode guard plugin; Cursor/Codebuddy/Grok/Kimi/ZCode hook
17
+ recipes; DSH always installs its native guard).
18
+ Cursor writes ~/.cursor/hooks.json (--client=cursor).
19
+ ZCode: PreToolUse only — never SessionEnd, never Stop cleanup
18
20
  --no-cursor-permissions
19
21
  Skip merging Cursor mcpAllowlist
20
22
  --cursor-permissions Force Cursor allowlist merge when Cursor is selected (default on)
@@ -9,6 +9,7 @@ const codebuddyMod = require('./codebuddy');
9
9
  const grokMod = require('./grok');
10
10
  const kimicodeMod = require('./kimicode');
11
11
  const zcodeMod = require('./zcode');
12
+ const cursorMod = require('./cursor');
12
13
  const PACKAGE_NAME = '@zlnap/astrolabe';
13
14
  const SERVER_NAME = 'astrolabe';
14
15
 
@@ -360,17 +361,60 @@ const CLIENTS = [
360
361
  }
361
362
  return { detected: evidence.length > 0, evidence };
362
363
  },
363
- plan({ home, launch }) {
364
+ plan({ home, launch, withHooks = false }) {
364
365
  const filePath = path.join(home, '.cursor', 'mcp.json');
366
+ const hooksPath = cursorMod.resolveCursorHooksPath(home);
365
367
  const entry = jsonServerEntry(launch);
366
- return {
368
+ const steps = [];
369
+ steps.push({
367
370
  kind: 'json-mcp',
368
371
  filePath,
369
- entry,
370
372
  summary: `merge mcpServers.astrolabe into ${filePath}`,
371
373
  apply() {
372
374
  const result = mergeJsonMcpFile(filePath, entry, { create: true });
373
- return { ...result, write: () => writeJsonAtomic(filePath, result.doc) };
375
+ return { ...result, entry, write: () => writeJsonAtomic(filePath, result.doc) };
376
+ },
377
+ });
378
+ if (withHooks) {
379
+ steps.push({
380
+ kind: 'cursor-hooks',
381
+ filePath: hooksPath,
382
+ summary: `merge preToolUse/sessionEnd into ${hooksPath} (--client=cursor)`,
383
+ apply() {
384
+ const create = exists(path.join(home, '.cursor')) || exists(hooksPath);
385
+ const result = cursorMod.mergeCursorHooksFile(hooksPath, {
386
+ create,
387
+ existsFn: exists,
388
+ });
389
+ if (result.skipped) return result;
390
+ return { ...result, write: () => writeJsonAtomic(hooksPath, result.doc) };
391
+ },
392
+ });
393
+ }
394
+ return {
395
+ kind: 'cursor',
396
+ filePath,
397
+ entry,
398
+ summary:
399
+ steps[0].summary +
400
+ (withHooks ? `; hooks → ${hooksPath} (--client=cursor, matcher incl. mcp__astrolabe__.*)` : ''),
401
+ steps,
402
+ apply() {
403
+ const results = [];
404
+ for (const step of steps) {
405
+ results.push(step.apply());
406
+ }
407
+ const primary = results.find((r) => !r.skipped) || results[0];
408
+ return {
409
+ ...primary,
410
+ entry,
411
+ results,
412
+ write: () => {
413
+ for (const r of results) {
414
+ if (!r.skipped && r.write) r.write();
415
+ }
416
+ },
417
+ };
374
418
  },
375
419
  };
376
420
  },
@@ -839,7 +883,7 @@ const CLIENTS = [
839
883
  filePath,
840
884
  summary:
841
885
  `merge mcp.servers.astrolabe into ${filePath}` +
842
- (withHooks ? ' (+ hooks.events.PreToolUse, no SessionEnd)' : ''),
886
+ (withHooks ? ' (+ hooks.events.PreToolUse; no SessionEnd; scrub Stop cleanup)' : ''),
843
887
  apply() {
844
888
  const create =
845
889
  exists(path.join(home, '.zcode')) ||
@@ -898,7 +942,7 @@ module.exports = {
898
942
  resolveDshPluginInstallDirs: dshMod.resolveDshPluginInstallDirs,
899
943
  OPENCODE_GUARD_TS: opencodeMod.OPENCODE_GUARD_TS,
900
944
  parseJsonc: require('./jsonc').parseJsonc,
901
- // Codebuddy / Grok / Kimi / ZCode helpers (tests)
945
+ // Codebuddy / Grok / Kimi / ZCode / Cursor helpers (tests)
902
946
  resolveCodebuddyMcpPath: codebuddyMod.resolveCodebuddyMcpPath,
903
947
  mergeCodebuddyHooksSettings: codebuddyMod.mergeCodebuddyHooksSettings,
904
948
  resolveGrokConfigToml: grokMod.resolveGrokConfigToml,
@@ -909,4 +953,7 @@ module.exports = {
909
953
  mergeKimiHooksToml: kimicodeMod.mergeKimiHooksToml,
910
954
  resolveZcodeConfigPath: zcodeMod.resolveZcodeConfigPath,
911
955
  mergeZcodeConfigFile: zcodeMod.mergeZcodeConfigFile,
956
+ resolveCursorHooksPath: cursorMod.resolveCursorHooksPath,
957
+ mergeCursorHooksFile: cursorMod.mergeCursorHooksFile,
958
+ CURSOR_REMIND_MATCHER: cursorMod.REMIND_MATCHER,
912
959
  };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const REMIND_CMD = 'astrolabe hooks remind --client=cursor';
7
+ const CLEANUP_CMD = 'astrolabe hooks cleanup';
8
+ /** Matcher must include mcp__astrolabe__.* so successful Astrolabe calls reset counters. */
9
+ const REMIND_MATCHER = 'Shell|Read|Grep|mcp__astrolabe__.*';
10
+
11
+ function resolveCursorHooksPath(home) {
12
+ return path.join(home, '.cursor', 'hooks.json');
13
+ }
14
+
15
+ function remindEntry() {
16
+ return {
17
+ command: REMIND_CMD,
18
+ matcher: REMIND_MATCHER,
19
+ };
20
+ }
21
+
22
+ function cleanupEntry() {
23
+ return {
24
+ command: CLEANUP_CMD,
25
+ };
26
+ }
27
+
28
+ function entryCommand(entry) {
29
+ if (!entry || typeof entry !== 'object') return '';
30
+ if (typeof entry.command === 'string') return entry.command;
31
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
32
+ for (const h of hooks) {
33
+ if (h && typeof h.command === 'string') return h.command;
34
+ }
35
+ return '';
36
+ }
37
+
38
+ function isAstrolabeRemind(entry) {
39
+ return entryCommand(entry).includes('astrolabe hooks remind');
40
+ }
41
+
42
+ function isAstrolabeCleanup(entry) {
43
+ return entryCommand(entry).includes('astrolabe hooks cleanup');
44
+ }
45
+
46
+ /**
47
+ * Merge ~/.cursor/hooks.json (Cursor CLI native format: camelCase events,
48
+ * flat command entries). Enforces --client=cursor and the documented matcher
49
+ * that includes mcp__astrolabe__.* so counter resets work.
50
+ */
51
+ function mergeCursorHooksFile(filePath, { create = true, existsFn = fs.existsSync } = {}) {
52
+ let created = false;
53
+ let doc = {};
54
+ if (existsFn(filePath)) {
55
+ const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
56
+ doc = JSON.parse(raw);
57
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
58
+ throw new Error(`${filePath} root must be a JSON object`);
59
+ }
60
+ } else if (!create) {
61
+ return { skipped: true, reason: `missing ${filePath} (will not create)` };
62
+ } else {
63
+ created = true;
64
+ }
65
+
66
+ if (doc.version == null) doc.version = 1;
67
+ if (!doc.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) {
68
+ doc.hooks = {};
69
+ }
70
+ const hooks = doc.hooks;
71
+ let replaced = false;
72
+
73
+ if (!Array.isArray(hooks.preToolUse)) hooks.preToolUse = [];
74
+ const remindIdx = hooks.preToolUse.findIndex(isAstrolabeRemind);
75
+ const nextRemind = remindEntry();
76
+ if (remindIdx >= 0) {
77
+ hooks.preToolUse[remindIdx] = nextRemind;
78
+ replaced = true;
79
+ } else {
80
+ hooks.preToolUse.push(nextRemind);
81
+ }
82
+
83
+ if (!Array.isArray(hooks.sessionEnd)) hooks.sessionEnd = [];
84
+ const cleanupIdx = hooks.sessionEnd.findIndex(isAstrolabeCleanup);
85
+ const nextCleanup = cleanupEntry();
86
+ if (cleanupIdx >= 0) {
87
+ hooks.sessionEnd[cleanupIdx] = nextCleanup;
88
+ replaced = true;
89
+ } else {
90
+ hooks.sessionEnd.push(nextCleanup);
91
+ }
92
+
93
+ return {
94
+ filePath,
95
+ doc,
96
+ created,
97
+ replaced,
98
+ entryPreview: JSON.stringify(
99
+ { version: doc.version, hooks: { preToolUse: [nextRemind], sessionEnd: [nextCleanup] } },
100
+ null,
101
+ 2,
102
+ ),
103
+ };
104
+ }
105
+
106
+ module.exports = {
107
+ REMIND_CMD,
108
+ CLEANUP_CMD,
109
+ REMIND_MATCHER,
110
+ resolveCursorHooksPath,
111
+ mergeCursorHooksFile,
112
+ };
package/lib/setup/run.js CHANGED
@@ -261,6 +261,15 @@ function run(argv = process.argv.slice(2)) {
261
261
  process.stdout.write(
262
262
  ' 3. Cursor: open Settings → MCP and confirm astrolabe is listed; enable Run Mode for allowlist.\n',
263
263
  );
264
+ if (opts.withHooks) {
265
+ process.stdout.write(
266
+ ' Hooks: ~/.cursor/hooks.json uses --client=cursor (matcher includes mcp__astrolabe__.*).\n',
267
+ );
268
+ } else {
269
+ process.stdout.write(
270
+ ' Tip: re-run with --with-hooks to write ~/.cursor/hooks.json (--client=cursor).\n',
271
+ );
272
+ }
264
273
  }
265
274
  if (selectedIds.includes('opencode')) {
266
275
  process.stdout.write(
@@ -294,7 +303,7 @@ function run(argv = process.argv.slice(2)) {
294
303
  }
295
304
  if (selectedIds.includes('zcode')) {
296
305
  process.stdout.write(
297
- ' 3. ZCode: MCP under mcp.servers in ~/.zcode/cli/config.json; tip: --with-hooks adds hooks.events.PreToolUse (no SessionEnd).\n',
306
+ ' 3. ZCode: MCP under mcp.servers in ~/.zcode/cli/config.json; tip: --with-hooks adds hooks.events.PreToolUse (--client=zcode; no SessionEnd; scrub Stop cleanup).\n',
298
307
  );
299
308
  }
300
309
  return summary.some((row) => row.status === 'written') || summary.every((row) => row.status === 'skipped')
@@ -84,9 +84,11 @@ and reset when the OS clears the temp directory.
84
84
 
85
85
  - Astrolabe identifies its own tools by substring, so DSH's per-repository server
86
86
  names (`mcp__astrolabe-<repo>__find_symbol`) need no rewriting.
87
- - `search_code` is a *non-symbolic* Astrolabe tool: it does not reset the
88
- grep/read counter (by Astrolabe's own design). Use `find_symbol` /
89
- `resolve_context` / `find_references` / `goto_definition` to reset it.
87
+ - Any Astrolabe catalog tool (including `search_code`) resets the
88
+ grep/read/non_symbolic/slice counters on PreToolUse and clears the
89
+ post-deny silence window. Prefer `find_symbol` /
90
+ `resolve_context` / `find_references` / `goto_definition` for navigation;
91
+ `search_code` is fine for recovery after a deny.
90
92
 
91
93
  ## Licence
92
94
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrolabe-dsh-guard",
3
- "version": "0.1.0",
3
+ "version": "0.17.2",
4
4
  "private": true,
5
5
  "description": "Astrolabe drift guard for DeepSeek Harness: deny repeated grep/read calls and steer the model to Astrolabe's MCP code-navigation tools.",
6
6
  "type": "module",
@@ -47,20 +47,54 @@ function preToolUseEntry() {
47
47
  };
48
48
  }
49
49
 
50
- function entryHasRemind(entry) {
50
+ function hookCommands(entry) {
51
+ if (!entry || typeof entry !== 'object') return [];
52
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
53
+ return hooks
54
+ .filter((h) => h && typeof h.command === 'string')
55
+ .map((h) => h.command);
56
+ }
57
+
58
+ function entryHasAstrolabeRemind(entry) {
59
+ return hookCommands(entry).some((cmd) => cmd.includes('astrolabe hooks remind'));
60
+ }
61
+
62
+ /**
63
+ * Strip cleanup hooks from a Stop entry; drop the entry if nothing remains.
64
+ * Returns { entry | null, stripped }.
65
+ */
66
+ function scrubStopEntry(entry) {
67
+ if (!entry || typeof entry !== 'object') return { entry: null, stripped: false };
68
+ if (!entryHasCleanup(entry)) return { entry, stripped: false };
69
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
70
+ const kept = hooks.filter(
71
+ (h) => !(h && typeof h.command === 'string' && h.command.includes('astrolabe hooks cleanup')),
72
+ );
73
+ if (!kept.length) return { entry: null, stripped: true };
74
+ return { entry: { ...entry, hooks: kept }, stripped: true };
75
+ }
76
+
77
+ /**
78
+ * True when a Stop (or similar) event entry hangs `astrolabe hooks cleanup`.
79
+ * ZCode fires Stop at every turn end — cleanup there would wipe counters each turn.
80
+ */
81
+ function entryHasCleanup(entry) {
51
82
  if (!entry || typeof entry !== 'object') return false;
52
83
  const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
53
84
  return hooks.some(
54
85
  (h) =>
55
86
  h &&
56
87
  typeof h.command === 'string' &&
57
- h.command.includes('astrolabe hooks remind --client=zcode'),
88
+ h.command.includes('astrolabe hooks cleanup'),
58
89
  );
59
90
  }
60
91
 
92
+
61
93
  /**
62
94
  * Merge mcp.servers.astrolabe and (optional) hooks.events.PreToolUse into
63
- * ~/.zcode/cli/config.json. Never writes SessionEnd; never hangs cleanup on Stop.
95
+ * ~/.zcode/cli/config.json. Never writes SessionEnd; never hangs cleanup on Stop
96
+ * (Stop is turn-end — use `astrolabe hooks gc`). Replaces any prior Astrolabe
97
+ * remind (including wrong --client=claude-code).
64
98
  */
65
99
  function mergeZcodeConfigFile(
66
100
  filePath,
@@ -102,11 +136,27 @@ function mergeZcodeConfigFile(
102
136
  // Hard rule: never SessionEnd (ZCode rejects unknown events).
103
137
  if (Object.prototype.hasOwnProperty.call(doc.hooks.events, 'SessionEnd')) {
104
138
  delete doc.hooks.events.SessionEnd;
139
+ hooksReplaced = true;
140
+ }
141
+ // Hard rule: never hang cleanup on Stop (fires every turn; would zero counters).
142
+ if (Array.isArray(doc.hooks.events.Stop)) {
143
+ const scrubbed = [];
144
+ for (const entry of doc.hooks.events.Stop) {
145
+ const { entry: next, stripped } = scrubStopEntry(entry);
146
+ if (stripped) hooksReplaced = true;
147
+ if (next) scrubbed.push(next);
148
+ }
149
+ if (scrubbed.length) {
150
+ doc.hooks.events.Stop = scrubbed;
151
+ } else {
152
+ delete doc.hooks.events.Stop;
153
+ }
105
154
  }
106
155
  if (!Array.isArray(doc.hooks.events.PreToolUse)) {
107
156
  doc.hooks.events.PreToolUse = [];
108
157
  }
109
- const idx = doc.hooks.events.PreToolUse.findIndex(entryHasRemind);
158
+ // Match any prior remind (wrong --client=claude-code included) and replace.
159
+ const idx = doc.hooks.events.PreToolUse.findIndex(entryHasAstrolabeRemind);
110
160
  const next = preToolUseEntry();
111
161
  if (idx >= 0) {
112
162
  doc.hooks.events.PreToolUse[idx] = next;
package/lib/setup.test.js CHANGED
@@ -453,6 +453,15 @@ test('ZCode mcp.servers + hooks.events without SessionEnd', () => {
453
453
  enabled: false,
454
454
  events: {
455
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
+ ],
456
465
  PreToolUse: [{ hooks: [{ type: 'command', command: 'echo keep' }] }],
457
466
  },
458
467
  },
@@ -476,6 +485,15 @@ test('ZCode mcp.servers + hooks.events without SessionEnd', () => {
476
485
  !JSON.stringify(doc.hooks).includes('SessionEnd'),
477
486
  'SessionEnd must not appear in ZCode hooks',
478
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
+ );
479
497
  assert.ok(
480
498
  doc.hooks.events.PreToolUse.some((e) =>
481
499
  (e.hooks || []).some((h) => String(h.command).includes('--client=zcode')),
@@ -509,6 +527,53 @@ test('ZCode mcp.servers + hooks.events without SessionEnd', () => {
509
527
  }
510
528
  });
511
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
+
512
577
  test('setup --clients aliases resolve for new clients', () => {
513
578
  const home = tmpHome();
514
579
  try {
@@ -537,3 +602,216 @@ test('setup --clients aliases resolve for new clients', () => {
537
602
  fs.rmSync(home, { recursive: true, force: true });
538
603
  }
539
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zlnap/astrolabe",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "mcpName": "io.github.wojiushirencai/astrolabe",
5
5
  "description": "Local MCP server for code search and navigation (search_code, find_symbol, find_references, goto_definition) for AI coding agents. Node shim for the native Astrolabe AI binary.",
6
6
  "keywords": [
@@ -40,12 +40,12 @@
40
40
  "node": ">=18"
41
41
  },
42
42
  "optionalDependencies": {
43
- "@zlnap/darwin-arm64": "0.17.0",
44
- "@zlnap/darwin-x64": "0.17.0",
45
- "@zlnap/linux-x64-gnu": "0.17.0",
46
- "@zlnap/linux-x64-musl": "0.17.0",
47
- "@zlnap/linux-arm64-gnu": "0.17.0",
48
- "@zlnap/win32-x64-msvc": "0.17.0"
43
+ "@zlnap/darwin-arm64": "0.17.2",
44
+ "@zlnap/darwin-x64": "0.17.2",
45
+ "@zlnap/linux-x64-gnu": "0.17.2",
46
+ "@zlnap/linux-x64-musl": "0.17.2",
47
+ "@zlnap/linux-arm64-gnu": "0.17.2",
48
+ "@zlnap/win32-x64-msvc": "0.17.2"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"