@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,185 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const REMIND_CMD = 'astrolabe hooks remind --client=zcode';
7
+
8
+ function detectZCode(home, env, { whichFn, existsFn }) {
9
+ const evidence = [];
10
+ if (whichFn('zcode', env)) evidence.push('zcode on PATH');
11
+ if (existsFn(path.join(home, '.zcode'))) evidence.push('~/.zcode/');
12
+ if (existsFn(path.join(home, '.zcode', 'cli'))) evidence.push('~/.zcode/cli/');
13
+ if (existsFn(path.join(home, '.zcode', 'cli', 'config.json'))) {
14
+ evidence.push('~/.zcode/cli/config.json');
15
+ }
16
+ return { detected: evidence.length > 0, evidence };
17
+ }
18
+
19
+ function resolveZcodeConfigPath(home, existsFn) {
20
+ // Prefer documented ~/.zcode/cli/config.json (HOOKS.md + z.ai docs).
21
+ // Some forks also use setting.json; only fall back when that alone exists.
22
+ const configJson = path.join(home, '.zcode', 'cli', 'config.json');
23
+ const settingJson = path.join(home, '.zcode', 'cli', 'setting.json');
24
+ if (existsFn(configJson)) return configJson;
25
+ if (existsFn(settingJson)) return settingJson;
26
+ return configJson;
27
+ }
28
+
29
+ function zcodeServerEntry(launch) {
30
+ return {
31
+ type: 'stdio',
32
+ command: launch.command,
33
+ args: launch.args,
34
+ };
35
+ }
36
+
37
+ function preToolUseEntry() {
38
+ return {
39
+ matcher: '*',
40
+ hooks: [
41
+ {
42
+ type: 'command',
43
+ command: REMIND_CMD,
44
+ timeout: 10,
45
+ },
46
+ ],
47
+ };
48
+ }
49
+
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) {
82
+ if (!entry || typeof entry !== 'object') return false;
83
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
84
+ return hooks.some(
85
+ (h) =>
86
+ h &&
87
+ typeof h.command === 'string' &&
88
+ h.command.includes('astrolabe hooks cleanup'),
89
+ );
90
+ }
91
+
92
+
93
+ /**
94
+ * Merge mcp.servers.astrolabe and (optional) hooks.events.PreToolUse into
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).
98
+ */
99
+ function mergeZcodeConfigFile(
100
+ filePath,
101
+ launch,
102
+ { create = true, withHooks = false, existsFn = fs.existsSync } = {},
103
+ ) {
104
+ let created = false;
105
+ let doc = {};
106
+ if (existsFn(filePath)) {
107
+ const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
108
+ doc = JSON.parse(raw);
109
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
110
+ throw new Error(`${filePath} root must be a JSON object`);
111
+ }
112
+ } else if (!create) {
113
+ return { skipped: true, reason: `missing ${filePath} (will not create)` };
114
+ } else {
115
+ created = true;
116
+ }
117
+
118
+ if (!doc.mcp || typeof doc.mcp !== 'object' || Array.isArray(doc.mcp)) {
119
+ doc.mcp = {};
120
+ }
121
+ if (!doc.mcp.servers || typeof doc.mcp.servers !== 'object' || Array.isArray(doc.mcp.servers)) {
122
+ doc.mcp.servers = {};
123
+ }
124
+ const hadServer = Object.prototype.hasOwnProperty.call(doc.mcp.servers, 'astrolabe');
125
+ doc.mcp.servers.astrolabe = zcodeServerEntry(launch);
126
+
127
+ let hooksReplaced = false;
128
+ if (withHooks) {
129
+ if (!doc.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) {
130
+ doc.hooks = {};
131
+ }
132
+ doc.hooks.enabled = true;
133
+ if (!doc.hooks.events || typeof doc.hooks.events !== 'object' || Array.isArray(doc.hooks.events)) {
134
+ doc.hooks.events = {};
135
+ }
136
+ // Hard rule: never SessionEnd (ZCode rejects unknown events).
137
+ if (Object.prototype.hasOwnProperty.call(doc.hooks.events, 'SessionEnd')) {
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
+ }
154
+ }
155
+ if (!Array.isArray(doc.hooks.events.PreToolUse)) {
156
+ doc.hooks.events.PreToolUse = [];
157
+ }
158
+ // Match any prior remind (wrong --client=claude-code included) and replace.
159
+ const idx = doc.hooks.events.PreToolUse.findIndex(entryHasAstrolabeRemind);
160
+ const next = preToolUseEntry();
161
+ if (idx >= 0) {
162
+ doc.hooks.events.PreToolUse[idx] = next;
163
+ hooksReplaced = true;
164
+ } else {
165
+ doc.hooks.events.PreToolUse.push(next);
166
+ }
167
+ }
168
+
169
+ return {
170
+ filePath,
171
+ doc,
172
+ created,
173
+ replaced: hadServer || hooksReplaced,
174
+ entry: doc.mcp.servers.astrolabe,
175
+ withHooks,
176
+ };
177
+ }
178
+
179
+ module.exports = {
180
+ REMIND_CMD,
181
+ detectZCode,
182
+ resolveZcodeConfigPath,
183
+ zcodeServerEntry,
184
+ mergeZcodeConfigFile,
185
+ };