@zlnap/astrolabe 0.16.0 → 0.17.0

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,94 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const MANAGED_BEGIN = '# >>> astrolabe-managed-kimi-hooks >>>';
7
+ const MANAGED_END = '# <<< astrolabe-managed-kimi-hooks <<<';
8
+
9
+ const MANAGED_BLOCK = `${MANAGED_BEGIN}
10
+ [[hooks]]
11
+ event = "PreToolUse"
12
+ matcher = "Read|Grep|Bash|mcp__astrolabe__.*"
13
+ command = "astrolabe hooks remind --client=kimicode"
14
+
15
+ [[hooks]]
16
+ event = "SessionEnd"
17
+ command = "astrolabe hooks cleanup"
18
+ ${MANAGED_END}
19
+ `;
20
+
21
+ function detectKimiCode(home, env, { whichFn, existsFn }) {
22
+ const evidence = [];
23
+ if (whichFn('kimi', env)) evidence.push('kimi on PATH');
24
+ if (existsFn(path.join(home, '.kimi-code'))) evidence.push('~/.kimi-code/');
25
+ if (existsFn(path.join(home, '.kimi-code', 'config.toml'))) {
26
+ evidence.push('~/.kimi-code/config.toml');
27
+ }
28
+ if (existsFn(path.join(home, '.kimi-code', 'mcp.json'))) {
29
+ evidence.push('~/.kimi-code/mcp.json');
30
+ }
31
+ return { detected: evidence.length > 0, evidence };
32
+ }
33
+
34
+ function resolveKimiMcpPath(home) {
35
+ return path.join(home, '.kimi-code', 'mcp.json');
36
+ }
37
+
38
+ function resolveKimiConfigToml(home) {
39
+ return path.join(home, '.kimi-code', 'config.toml');
40
+ }
41
+
42
+ /**
43
+ * Merge managed [[hooks]] into ~/.kimi-code/config.toml without touching other
44
+ * tables or user hook blocks (same marker strategy as DSH cordis patch).
45
+ */
46
+ function mergeKimiHooksToml(filePath, { create = true, existsFn = fs.existsSync } = {}) {
47
+ let created = false;
48
+ let text = '';
49
+ if (existsFn(filePath)) {
50
+ text = fs.readFileSync(filePath, 'utf8');
51
+ } else if (!create) {
52
+ return { skipped: true, reason: `missing ${filePath} (will not create)` };
53
+ } else {
54
+ created = true;
55
+ text = '';
56
+ }
57
+
58
+ const begin = text.indexOf(MANAGED_BEGIN);
59
+ const end = text.indexOf(MANAGED_END);
60
+ let next;
61
+ let replaced = false;
62
+ if (begin >= 0 && end > begin) {
63
+ replaced = true;
64
+ const afterEnd = end + MANAGED_END.length;
65
+ let tail = text.slice(afterEnd);
66
+ if (tail.startsWith('\n')) tail = tail.slice(1);
67
+ next = `${text.slice(0, begin)}${MANAGED_BLOCK}${tail}`;
68
+ } else if (begin >= 0 || end >= 0) {
69
+ throw new Error(
70
+ `${filePath} has a broken astrolabe-managed-kimi-hooks marker; fix or remove it, then re-run setup`,
71
+ );
72
+ } else {
73
+ const trimmed = text.replace(/\s*$/, '');
74
+ next = trimmed ? `${trimmed}\n\n${MANAGED_BLOCK}` : `${MANAGED_BLOCK}`;
75
+ }
76
+ if (!next.endsWith('\n')) next += '\n';
77
+ return {
78
+ filePath,
79
+ text: next,
80
+ created,
81
+ replaced,
82
+ entryPreview: MANAGED_BLOCK.trim(),
83
+ };
84
+ }
85
+
86
+ module.exports = {
87
+ MANAGED_BEGIN,
88
+ MANAGED_END,
89
+ MANAGED_BLOCK,
90
+ detectKimiCode,
91
+ resolveKimiMcpPath,
92
+ resolveKimiConfigToml,
93
+ mergeKimiHooksToml,
94
+ };
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { parseJsonc, upsertJsoncProperty } = require('./jsonc');
6
+
7
+ const OPENCODE_GUARD_TS = `import type { Plugin } from "@opencode-ai/plugin";
8
+
9
+ export const AstrolabeGuard: Plugin = async () => {
10
+ return {
11
+ "tool.execute.before": async (input, output) => {
12
+ const payload = JSON.stringify({
13
+ session_id: input.sessionID,
14
+ tool_name: input.tool,
15
+ tool_input: output.args ?? {},
16
+ });
17
+ const proc = Bun.spawnSync(["astrolabe", "hooks", "remind", "--client=opencode"], {
18
+ stdin: payload,
19
+ stdout: "pipe",
20
+ stderr: "pipe",
21
+ });
22
+ const stdout = new TextDecoder().decode(proc.stdout).trim();
23
+ if (stdout) {
24
+ const decision = JSON.parse(stdout);
25
+ if (decision.decision === "deny") {
26
+ throw new Error(decision.reason);
27
+ }
28
+ }
29
+ },
30
+ };
31
+ };
32
+ export default AstrolabeGuard;
33
+ `;
34
+
35
+ function resolveOpenCodeConfigPath(home, existsFn) {
36
+ const candidates = [
37
+ path.join(home, '.config', 'opencode', 'opencode.jsonc'),
38
+ path.join(home, '.config', 'opencode', 'opencode.json'),
39
+ path.join(home, '.config', 'opencode', 'config.json'),
40
+ path.join(home, '.opencode', 'opencode.jsonc'),
41
+ path.join(home, '.opencode', 'opencode.json'),
42
+ path.join(home, '.opencode', 'config.json'),
43
+ ];
44
+ for (const cand of candidates) {
45
+ if (existsFn(cand)) return cand;
46
+ }
47
+ // Default: new-style user config (JSONC).
48
+ return path.join(home, '.config', 'opencode', 'opencode.jsonc');
49
+ }
50
+
51
+ /**
52
+ * @returns {'v1'|'v2'|'mcpServers'}
53
+ */
54
+ function detectOpenCodeMcpFormat(doc) {
55
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return 'v1';
56
+ if (doc.mcpServers && typeof doc.mcpServers === 'object' && !Array.isArray(doc.mcpServers)) {
57
+ return 'mcpServers';
58
+ }
59
+ if (doc.mcp && typeof doc.mcp === 'object' && !Array.isArray(doc.mcp)) {
60
+ if (doc.mcp.servers && typeof doc.mcp.servers === 'object' && !Array.isArray(doc.mcp.servers)) {
61
+ return 'v2';
62
+ }
63
+ return 'v1';
64
+ }
65
+ // New file: prefer V1 (OpenCode 1.x / docs.opencode.ai classic shape).
66
+ return 'v1';
67
+ }
68
+
69
+ function openCodeServerEntry(launch, format) {
70
+ const command = [launch.command, ...launch.args];
71
+ if (format === 'mcpServers') {
72
+ return { command: launch.command, args: launch.args };
73
+ }
74
+ if (format === 'v2') {
75
+ return { type: 'local', command };
76
+ }
77
+ // v1
78
+ return { type: 'local', command, enabled: true };
79
+ }
80
+
81
+ /**
82
+ * Merge Astrolabe into an OpenCode config file, preserving JSONC comments when
83
+ * the parent `mcp` / `mcp.servers` object already exists.
84
+ */
85
+ function mergeOpenCodeMcpFile(filePath, launch, { create = true, existsFn = fs.existsSync } = {}) {
86
+ let created = false;
87
+ let raw = '';
88
+ let format = 'v1';
89
+ if (existsFn(filePath)) {
90
+ raw = fs.readFileSync(filePath, 'utf8');
91
+ try {
92
+ format = detectOpenCodeMcpFormat(parseJsonc(raw));
93
+ } catch (err) {
94
+ throw new Error(`${filePath} is not valid JSON/JSONC: ${err.message}`);
95
+ }
96
+ } else if (!create) {
97
+ return { skipped: true, reason: `missing ${filePath} (will not create)` };
98
+ } else {
99
+ created = true;
100
+ raw = '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
101
+ }
102
+
103
+ const entry = openCodeServerEntry(launch, format);
104
+ let parentPath;
105
+ if (format === 'mcpServers') parentPath = ['mcpServers'];
106
+ else if (format === 'v2') parentPath = ['mcp', 'servers'];
107
+ else parentPath = ['mcp'];
108
+
109
+ const result = upsertJsoncProperty(raw, parentPath, 'astrolabe', entry, { indent: 2 });
110
+ return {
111
+ filePath,
112
+ text: result.text,
113
+ created,
114
+ replaced: result.replaced,
115
+ format,
116
+ entry,
117
+ commentsPreserved: result.commentsPreserved !== false,
118
+ write: null, // filled by caller with writeTextAtomic
119
+ };
120
+ }
121
+
122
+ function detectOpenCode(home, env, { whichFn, existsFn }) {
123
+ const evidence = [];
124
+ if (whichFn('opencode', env)) evidence.push('opencode on PATH');
125
+ if (existsFn(path.join(home, '.config', 'opencode'))) evidence.push('~/.config/opencode/');
126
+ if (existsFn(path.join(home, '.opencode'))) evidence.push('~/.opencode/');
127
+ return { detected: evidence.length > 0, evidence };
128
+ }
129
+
130
+ function planOpenCodeGuardPlugin(home, existsFn) {
131
+ // Prefer user-level plugins dir used by OpenCode 1.x on this Mac.
132
+ const userPlugins = path.join(home, '.config', 'opencode', 'plugins');
133
+ const filePath = path.join(userPlugins, 'astrolabe-guard.ts');
134
+ return {
135
+ kind: 'opencode-guard',
136
+ filePath,
137
+ summary: `write OpenCode guard plugin ${filePath}`,
138
+ apply() {
139
+ const created = !existsFn(filePath);
140
+ return {
141
+ filePath,
142
+ created,
143
+ replaced: !created,
144
+ text: OPENCODE_GUARD_TS,
145
+ write: null,
146
+ };
147
+ },
148
+ };
149
+ }
150
+
151
+ module.exports = {
152
+ OPENCODE_GUARD_TS,
153
+ resolveOpenCodeConfigPath,
154
+ detectOpenCodeMcpFormat,
155
+ openCodeServerEntry,
156
+ mergeOpenCodeMcpFile,
157
+ detectOpenCode,
158
+ planOpenCodeGuardPlugin,
159
+ };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ function isTTY() {
6
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
7
+ }
8
+
9
+ function readLine(question) {
10
+ process.stdout.write(question);
11
+ if (!process.stdin.isTTY) {
12
+ return '';
13
+ }
14
+ const buf = Buffer.alloc(1024);
15
+ let n = 0;
16
+ try {
17
+ n = fs.readSync(0, buf, 0, buf.length, null);
18
+ } catch {
19
+ return '';
20
+ }
21
+ return buf.toString('utf8', 0, n).replace(/\r?\n$/, '');
22
+ }
23
+
24
+ function confirm(question, { defaultYes = false } = {}) {
25
+ const hint = defaultYes ? 'Y/n' : 'y/N';
26
+ const answer = readLine(`${question} [${hint}] `).trim().toLowerCase();
27
+ if (!answer) return defaultYes;
28
+ return answer === 'y' || answer === 'yes';
29
+ }
30
+
31
+ /**
32
+ * Multi-select from rows: [{ id, label, detected, evidence }]
33
+ * Returns selected ids.
34
+ */
35
+ function selectClients(rows, { preselectDetected = true } = {}) {
36
+ if (!isTTY()) {
37
+ return rows.filter((r) => r.detected).map((r) => r.id);
38
+ }
39
+
40
+ process.stdout.write('\nDetected / known agents:\n');
41
+ rows.forEach((row, i) => {
42
+ const mark = row.detected ? '*' : ' ';
43
+ const status = row.detected
44
+ ? `detected (${row.evidence.join(', ')})`
45
+ : 'not detected';
46
+ process.stdout.write(` ${i + 1}. [${mark}] ${row.label} (${row.id}) — ${status}\n`);
47
+ });
48
+ process.stdout.write(
49
+ '\nEnter numbers to configure (e.g. 1,2), "d" for all detected, "a" for all, or empty to cancel:\n> ',
50
+ );
51
+
52
+ const answer = readLine('').trim().toLowerCase();
53
+ if (!answer) return [];
54
+ if (answer === 'd') return rows.filter((r) => r.detected).map((r) => r.id);
55
+ if (answer === 'a') return rows.map((r) => r.id);
56
+
57
+ const selected = new Set();
58
+ for (const part of answer.split(/[,\s]+/)) {
59
+ if (!part) continue;
60
+ const n = Number(part);
61
+ if (!Number.isInteger(n) || n < 1 || n > rows.length) {
62
+ throw new Error(`invalid selection: ${part}`);
63
+ }
64
+ selected.add(rows[n - 1].id);
65
+ }
66
+ return [...selected];
67
+ }
68
+
69
+ module.exports = { isTTY, readLine, confirm, selectClients };
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const {
6
+ CLIENTS,
7
+ getClient,
8
+ canonicalClientId,
9
+ resolveLaunch,
10
+ mergeCursorPermissions,
11
+ backupFile,
12
+ writeJsonAtomic,
13
+ } = require('./clients');
14
+ const { parseArgs, USAGE } = require('./args');
15
+ const { isTTY, confirm, selectClients } = require('./prompt');
16
+
17
+ function resolveHome(homeFlag) {
18
+ if (homeFlag) return path.resolve(homeFlag);
19
+ if (process.env.HOME) return process.env.HOME;
20
+ if (process.env.USERPROFILE) return process.env.USERPROFILE;
21
+ return os.homedir();
22
+ }
23
+
24
+ function detectAll(home) {
25
+ return CLIENTS.map((client) => {
26
+ const { detected, evidence } = client.detect(home);
27
+ return {
28
+ id: client.id,
29
+ label: client.label,
30
+ context: client.context,
31
+ detected,
32
+ evidence,
33
+ };
34
+ });
35
+ }
36
+
37
+ function printList(rows) {
38
+ for (const row of rows) {
39
+ const status = row.detected ? 'detected' : 'missing';
40
+ const ev = row.evidence.length ? ` (${row.evidence.join('; ')})` : '';
41
+ process.stdout.write(`${row.id.padEnd(18)} ${status}${ev}\n`);
42
+ }
43
+ }
44
+
45
+ function chooseIds(rows, opts) {
46
+ if (opts.clients && opts.clients.length) {
47
+ const unknown = opts.clients.filter((id) => !getClient(id));
48
+ if (unknown.length) {
49
+ throw new Error(`unknown client id(s): ${unknown.join(', ')}`);
50
+ }
51
+ // Deduplicate aliases onto canonical ids (e.g. dsh → deepseek-harness).
52
+ return [...new Set(opts.clients.map((id) => canonicalClientId(id)))];
53
+ }
54
+ if (opts.yes) {
55
+ const detected = rows.filter((r) => r.detected).map((r) => r.id);
56
+ if (!detected.length) {
57
+ throw new Error('no clients detected; pass --clients=... or run interactively');
58
+ }
59
+ return detected;
60
+ }
61
+ if (!isTTY()) {
62
+ throw new Error(
63
+ 'stdin is not a TTY; pass --yes (all detected) or --clients=claude-code,cursor,...',
64
+ );
65
+ }
66
+ return selectClients(rows);
67
+ }
68
+
69
+ function formatEntryPreview(planResult) {
70
+ if (planResult.entryPreview) return String(planResult.entryPreview).trimEnd();
71
+ if (planResult.entry) {
72
+ return JSON.stringify({ astrolabe: planResult.entry }, null, 2);
73
+ }
74
+ if (planResult.doc && planResult.doc.mcpServers) {
75
+ return JSON.stringify(
76
+ { mcpServers: { astrolabe: planResult.doc.mcpServers.astrolabe } },
77
+ null,
78
+ 2,
79
+ );
80
+ }
81
+ if (planResult.text) {
82
+ const match = planResult.text.match(/\[mcp_servers\.astrolabe\][\s\S]*?(?=\n\[|$)/);
83
+ if (match) return match[0].trimEnd();
84
+ const managed = planResult.text.match(
85
+ /# >>> astrolabe-managed-dsh-guard >>>[\s\S]*?# <<< astrolabe-managed-dsh-guard <<</,
86
+ );
87
+ if (managed) return managed[0].trimEnd();
88
+ // OpenCode: show a short snippet around "astrolabe"
89
+ const idx = planResult.text.indexOf('"astrolabe"');
90
+ if (idx >= 0) {
91
+ const start = Math.max(0, planResult.text.lastIndexOf('\n', idx) - 80);
92
+ return planResult.text.slice(start, Math.min(planResult.text.length, idx + 220)).trimEnd();
93
+ }
94
+ return planResult.text.trimEnd();
95
+ }
96
+ return '';
97
+ }
98
+
99
+ function run(argv = process.argv.slice(2)) {
100
+ const opts = parseArgs(argv);
101
+ if (opts.help) {
102
+ process.stdout.write(USAGE);
103
+ return 0;
104
+ }
105
+
106
+ const home = resolveHome(opts.home);
107
+ const rows = detectAll(home);
108
+
109
+ if (opts.list) {
110
+ printList(rows);
111
+ return 0;
112
+ }
113
+
114
+ let selectedIds;
115
+ try {
116
+ selectedIds = chooseIds(rows, opts);
117
+ } catch (error) {
118
+ process.stderr.write(`${error.message}\n`);
119
+ return 1;
120
+ }
121
+ if (!selectedIds.length) {
122
+ process.stdout.write('Nothing selected; no files changed.\n');
123
+ return 0;
124
+ }
125
+
126
+ const actions = [];
127
+ for (const id of selectedIds) {
128
+ const client = getClient(id);
129
+ const launch = resolveLaunch({
130
+ context: client.context,
131
+ native: opts.native,
132
+ commandOverride: opts.command,
133
+ });
134
+ const plan = client.plan({ home, launch, withHooks: opts.withHooks });
135
+ const applied = plan.apply();
136
+ actions.push({
137
+ id,
138
+ label: client.label,
139
+ summary: plan.summary,
140
+ launch,
141
+ applied,
142
+ });
143
+ }
144
+
145
+ let permissionsPlan = null;
146
+ if (opts.cursorPermissions && selectedIds.includes('cursor')) {
147
+ permissionsPlan = mergeCursorPermissions(home, {
148
+ dryRun: opts.dryRun,
149
+ yes: opts.yes,
150
+ });
151
+ }
152
+
153
+ process.stdout.write('\nPlan:\n');
154
+ for (const action of actions) {
155
+ if (action.applied.skipped) {
156
+ process.stdout.write(` - ${action.label}: SKIP ${action.applied.reason}\n`);
157
+ continue;
158
+ }
159
+ const op = action.applied.created ? 'create' : action.applied.replaced ? 'update' : 'write';
160
+ process.stdout.write(` - ${action.label}: ${op} ${action.applied.filePath}\n`);
161
+ if (action.applied.installDirs) {
162
+ for (const dir of action.applied.installDirs) {
163
+ process.stdout.write(` plugin → ${dir}\n`);
164
+ }
165
+ }
166
+ if (Array.isArray(action.applied.results)) {
167
+ for (const step of action.applied.results) {
168
+ if (!step || step.skipped) continue;
169
+ if (step.filePath && step.filePath !== action.applied.filePath) {
170
+ const sop = step.created ? 'create' : step.replaced ? 'update' : 'write';
171
+ process.stdout.write(` ${sop} ${step.filePath}\n`);
172
+ }
173
+ }
174
+ }
175
+ if (action.id !== 'deepseek-harness') {
176
+ process.stdout.write(` command: ${action.launch.command} ${action.launch.args.join(' ')}\n`);
177
+ }
178
+ const preview = formatEntryPreview(action.applied);
179
+ if (preview) {
180
+ for (const line of preview.split('\n')) {
181
+ process.stdout.write(` ${line}\n`);
182
+ }
183
+ }
184
+ }
185
+ if (permissionsPlan) {
186
+ process.stdout.write(
187
+ ` - Cursor permissions: merge ${permissionsPlan.added} allowlist entries into ${permissionsPlan.filePath}` +
188
+ `${permissionsPlan.created ? ' (new file)' : ''}\n`,
189
+ );
190
+ }
191
+
192
+ if (opts.dryRun) {
193
+ process.stdout.write('\n[dry-run] No files written.\n');
194
+ return 0;
195
+ }
196
+
197
+ if (!opts.yes && isTTY()) {
198
+ if (!confirm('Apply these changes?', { defaultYes: false })) {
199
+ process.stdout.write('Cancelled; no files written.\n');
200
+ return 1;
201
+ }
202
+ } else if (!opts.yes && !isTTY()) {
203
+ process.stderr.write('Refusing to write without --yes in non-interactive mode.\n');
204
+ return 1;
205
+ }
206
+
207
+ const summary = [];
208
+ for (const action of actions) {
209
+ if (action.applied.skipped) {
210
+ summary.push({ id: action.id, status: 'skipped', reason: action.applied.reason });
211
+ continue;
212
+ }
213
+ const backup = backupFile(action.applied.filePath);
214
+ action.applied.write();
215
+ summary.push({
216
+ id: action.id,
217
+ status: 'written',
218
+ file: action.applied.filePath,
219
+ backup,
220
+ });
221
+ process.stdout.write(
222
+ `Wrote ${action.applied.filePath}${backup ? ` (backup ${backup})` : ''}\n`,
223
+ );
224
+ }
225
+
226
+ if (permissionsPlan) {
227
+ if (permissionsPlan.created && !opts.yes && isTTY()) {
228
+ process.stdout.write(
229
+ `\nNote: defining mcpAllowlist replaces the IDE MCP allowlist table. ` +
230
+ `Other MCP tools must be listed in the same file. Cursor Run Mode must be on.\n`,
231
+ );
232
+ if (!confirm(`Create ${permissionsPlan.filePath}?`, { defaultYes: false })) {
233
+ process.stdout.write('Skipped Cursor permissions.json\n');
234
+ permissionsPlan = null;
235
+ }
236
+ }
237
+ if (permissionsPlan) {
238
+ const backup = backupFile(permissionsPlan.filePath);
239
+ writeJsonAtomic(permissionsPlan.filePath, permissionsPlan.doc);
240
+ process.stdout.write(
241
+ `Merged Cursor allowlist ${permissionsPlan.filePath} ` +
242
+ `(+${permissionsPlan.added}, total ${permissionsPlan.total})` +
243
+ `${backup ? `; backup ${backup}` : ''}\n`,
244
+ );
245
+ summary.push({
246
+ id: 'cursor-permissions',
247
+ status: 'written',
248
+ file: permissionsPlan.filePath,
249
+ backup,
250
+ });
251
+ }
252
+ }
253
+
254
+ process.stdout.write('\nDone. Next steps:\n');
255
+ process.stdout.write(' 1. Restart each configured agent / IDE so it reloads MCP servers.\n');
256
+ process.stdout.write(' 2. Verify: npx -y @zlnap/astrolabe --version\n');
257
+ if (selectedIds.includes('claude-code')) {
258
+ process.stdout.write(' 3. Claude Code: claude mcp get astrolabe\n');
259
+ }
260
+ if (selectedIds.includes('cursor')) {
261
+ process.stdout.write(
262
+ ' 3. Cursor: open Settings → MCP and confirm astrolabe is listed; enable Run Mode for allowlist.\n',
263
+ );
264
+ }
265
+ if (selectedIds.includes('opencode')) {
266
+ process.stdout.write(
267
+ ' 3. OpenCode: restart opencode; MCP is under mcp / mcp.servers in ~/.config/opencode/.\n',
268
+ );
269
+ if (!opts.withHooks) {
270
+ process.stdout.write(
271
+ ' Tip: re-run with --with-hooks to install plugins/astrolabe-guard.ts\n',
272
+ );
273
+ }
274
+ }
275
+ if (selectedIds.includes('deepseek-harness')) {
276
+ process.stdout.write(
277
+ ' 3. DSH: native guard is in cordis.patch.yml; add the Astrolabe MCP server per repo in the DSH UI.\n',
278
+ );
279
+ }
280
+ if (selectedIds.includes('codebuddy')) {
281
+ process.stdout.write(
282
+ ' 3. Codebuddy: MCP in ~/.codebuddy/.mcp.json; restart codebuddy. Tip: --with-hooks merges ~/.codebuddy/settings.json.\n',
283
+ );
284
+ }
285
+ if (selectedIds.includes('grok')) {
286
+ process.stdout.write(
287
+ ' 3. Grok Build: MCP in ~/.grok/config.toml ([mcp_servers.astrolabe]); tip: --with-hooks writes ~/.grok/hooks/astrolabe.json.\n',
288
+ );
289
+ }
290
+ if (selectedIds.includes('kimicode')) {
291
+ process.stdout.write(
292
+ ' 3. Kimi Code: MCP in ~/.kimi-code/mcp.json; tip: --with-hooks merges [[hooks]] into config.toml.\n',
293
+ );
294
+ }
295
+ if (selectedIds.includes('zcode')) {
296
+ 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',
298
+ );
299
+ }
300
+ return summary.some((row) => row.status === 'written') || summary.every((row) => row.status === 'skipped')
301
+ ? 0
302
+ : 0;
303
+ }
304
+
305
+ module.exports = { run, detectAll, resolveHome };