@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.
- package/README.md +10 -2
- package/bin/astrolabe.js +24 -10
- package/lib/setup/args.js +87 -0
- package/lib/setup/clients.js +959 -0
- package/lib/setup/codebuddy.js +93 -0
- package/lib/setup/cursor.js +112 -0
- package/lib/setup/dsh.js +137 -0
- package/lib/setup/grok.js +81 -0
- package/lib/setup/index.js +16 -0
- package/lib/setup/jsonc.js +334 -0
- package/lib/setup/kimicode.js +94 -0
- package/lib/setup/opencode.js +159 -0
- package/lib/setup/prompt.js +69 -0
- package/lib/setup/run.js +314 -0
- package/lib/setup/vendor/astrolabe-dsh-guard/README.md +95 -0
- package/lib/setup/vendor/astrolabe-dsh-guard/cordis.patch.yml +11 -0
- package/lib/setup/vendor/astrolabe-dsh-guard/lib/index.js +145 -0
- package/lib/setup/vendor/astrolabe-dsh-guard/package.json +38 -0
- package/lib/setup/zcode.js +185 -0
- package/lib/setup.test.js +817 -0
- package/package.json +7 -7
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal JSONC helpers for merge-only edits that preserve surrounding comments.
|
|
5
|
+
* Not a full JSONC parser: only brace-aware object-property upsert.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function stripJsonc(raw) {
|
|
9
|
+
let out = '';
|
|
10
|
+
let i = 0;
|
|
11
|
+
let inStr = false;
|
|
12
|
+
let esc = false;
|
|
13
|
+
while (i < raw.length) {
|
|
14
|
+
const c = raw[i];
|
|
15
|
+
if (inStr) {
|
|
16
|
+
out += c;
|
|
17
|
+
if (esc) esc = false;
|
|
18
|
+
else if (c === '\\') esc = true;
|
|
19
|
+
else if (c === '"') inStr = false;
|
|
20
|
+
i += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (c === '"') {
|
|
24
|
+
inStr = true;
|
|
25
|
+
out += c;
|
|
26
|
+
i += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (c === '/' && raw[i + 1] === '/') {
|
|
30
|
+
i += 2;
|
|
31
|
+
while (i < raw.length && raw[i] !== '\n') i += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (c === '/' && raw[i + 1] === '*') {
|
|
35
|
+
i += 2;
|
|
36
|
+
while (i + 1 < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i += 1;
|
|
37
|
+
i += 2;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
out += c;
|
|
41
|
+
i += 1;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseJsonc(raw) {
|
|
47
|
+
return JSON.parse(stripJsonc(raw.replace(/^\uFEFF/, '')));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findMatchingBrace(text, openIdx) {
|
|
51
|
+
if (openIdx < 0 || text[openIdx] !== '{') return -1;
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let inStr = false;
|
|
54
|
+
let esc = false;
|
|
55
|
+
for (let i = openIdx; i < text.length; i += 1) {
|
|
56
|
+
const c = text[i];
|
|
57
|
+
if (inStr) {
|
|
58
|
+
if (esc) {
|
|
59
|
+
esc = false;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (c === '\\') {
|
|
63
|
+
esc = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (c === '"') inStr = false;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (c === '"') {
|
|
70
|
+
inStr = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (c === '{') depth += 1;
|
|
74
|
+
else if (c === '}') {
|
|
75
|
+
depth -= 1;
|
|
76
|
+
if (depth === 0) return i;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find the `{` that opens the object at a dotted path of string keys from root.
|
|
84
|
+
* Returns index of `{` or -1.
|
|
85
|
+
*/
|
|
86
|
+
function findObjectOpen(text, pathKeys) {
|
|
87
|
+
const stripped = stripJsonc(text);
|
|
88
|
+
// Walk on stripped text to find offsets, then map... actually comments shift
|
|
89
|
+
// offsets. Walk the original with a comment-aware scanner instead.
|
|
90
|
+
let pos = text.search(/\{/);
|
|
91
|
+
if (pos < 0) return -1;
|
|
92
|
+
// Root object open:
|
|
93
|
+
// skip BOM/whitespace/comments to first {
|
|
94
|
+
pos = indexOfFirstBrace(text);
|
|
95
|
+
if (pos < 0) return -1;
|
|
96
|
+
|
|
97
|
+
for (const key of pathKeys) {
|
|
98
|
+
const prop = findPropertyValueStart(text, pos, key);
|
|
99
|
+
if (prop < 0) return -1;
|
|
100
|
+
// skip whitespace to value
|
|
101
|
+
let i = prop;
|
|
102
|
+
while (i < text.length && /\s/.test(text[i])) i += 1;
|
|
103
|
+
if (text[i] !== '{') return -1;
|
|
104
|
+
pos = i;
|
|
105
|
+
}
|
|
106
|
+
return pos;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function indexOfFirstBrace(text) {
|
|
110
|
+
let i = 0;
|
|
111
|
+
let inStr = false;
|
|
112
|
+
let esc = false;
|
|
113
|
+
while (i < text.length) {
|
|
114
|
+
const c = text[i];
|
|
115
|
+
if (inStr) {
|
|
116
|
+
if (esc) esc = false;
|
|
117
|
+
else if (c === '\\') esc = true;
|
|
118
|
+
else if (c === '"') inStr = false;
|
|
119
|
+
i += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === '"') {
|
|
123
|
+
inStr = true;
|
|
124
|
+
i += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (c === '/' && text[i + 1] === '/') {
|
|
128
|
+
i += 2;
|
|
129
|
+
while (i < text.length && text[i] !== '\n') i += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
133
|
+
i += 2;
|
|
134
|
+
while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
|
|
135
|
+
i += 2;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (c === '{') return i;
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
return -1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Inside object starting at openIdx, find the start index of the value for "key".
|
|
146
|
+
*/
|
|
147
|
+
function findPropertyValueStart(text, openIdx, key) {
|
|
148
|
+
const end = findMatchingBrace(text, openIdx);
|
|
149
|
+
if (end < 0) return -1;
|
|
150
|
+
const needle = `"${key}"`;
|
|
151
|
+
let i = openIdx + 1;
|
|
152
|
+
let inStr = false;
|
|
153
|
+
let esc = false;
|
|
154
|
+
let depth = 0; // relative depth inside arrays/objects under this object
|
|
155
|
+
while (i < end) {
|
|
156
|
+
const c = text[i];
|
|
157
|
+
if (inStr) {
|
|
158
|
+
if (esc) esc = false;
|
|
159
|
+
else if (c === '\\') esc = true;
|
|
160
|
+
else if (c === '"') inStr = false;
|
|
161
|
+
i += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (c === '"') {
|
|
165
|
+
if (depth === 0 && text.startsWith(needle, i)) {
|
|
166
|
+
let j = i + needle.length;
|
|
167
|
+
while (j < end && /\s/.test(text[j])) j += 1;
|
|
168
|
+
if (text[j] === ':') {
|
|
169
|
+
j += 1;
|
|
170
|
+
while (j < end && /\s/.test(text[j])) j += 1;
|
|
171
|
+
return j;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
inStr = true;
|
|
175
|
+
i += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (c === '/' && text[i + 1] === '/') {
|
|
179
|
+
i += 2;
|
|
180
|
+
while (i < end && text[i] !== '\n') i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
184
|
+
i += 2;
|
|
185
|
+
while (i + 1 < end && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
|
|
186
|
+
i += 2;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (c === '{' || c === '[') depth += 1;
|
|
190
|
+
else if (c === '}' || c === ']') depth -= 1;
|
|
191
|
+
i += 1;
|
|
192
|
+
}
|
|
193
|
+
return -1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function skipJsonValue(text, start) {
|
|
197
|
+
let i = start;
|
|
198
|
+
while (i < text.length && /\s/.test(text[i])) i += 1;
|
|
199
|
+
if (i >= text.length) return start;
|
|
200
|
+
const c = text[i];
|
|
201
|
+
if (c === '{') {
|
|
202
|
+
const end = findMatchingBrace(text, i);
|
|
203
|
+
return end < 0 ? start : end + 1;
|
|
204
|
+
}
|
|
205
|
+
if (c === '[') {
|
|
206
|
+
let depth = 0;
|
|
207
|
+
let inStr = false;
|
|
208
|
+
let esc = false;
|
|
209
|
+
for (let j = i; j < text.length; j += 1) {
|
|
210
|
+
const ch = text[j];
|
|
211
|
+
if (inStr) {
|
|
212
|
+
if (esc) esc = false;
|
|
213
|
+
else if (ch === '\\') esc = true;
|
|
214
|
+
else if (ch === '"') inStr = false;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (ch === '"') {
|
|
218
|
+
inStr = true;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (ch === '[') depth += 1;
|
|
222
|
+
else if (ch === ']') {
|
|
223
|
+
depth -= 1;
|
|
224
|
+
if (depth === 0) return j + 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return start;
|
|
228
|
+
}
|
|
229
|
+
if (c === '"') {
|
|
230
|
+
let esc = false;
|
|
231
|
+
for (let j = i + 1; j < text.length; j += 1) {
|
|
232
|
+
const ch = text[j];
|
|
233
|
+
if (esc) {
|
|
234
|
+
esc = false;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (ch === '\\') {
|
|
238
|
+
esc = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (ch === '"') return j + 1;
|
|
242
|
+
}
|
|
243
|
+
return start;
|
|
244
|
+
}
|
|
245
|
+
// number / literal
|
|
246
|
+
while (i < text.length && /[^\s,\}\]]/.test(text[i])) i += 1;
|
|
247
|
+
return i;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Upsert key: valueObject into the object at parentPath inside raw JSONC.
|
|
252
|
+
* valueObject is a plain JS object; serialized with JSON.stringify (2-space).
|
|
253
|
+
* Returns { text, replaced, createdParent }.
|
|
254
|
+
*/
|
|
255
|
+
function upsertJsoncProperty(raw, parentPath, key, valueObject, { indent = 2 } = {}) {
|
|
256
|
+
let text = raw.replace(/^\uFEFF/, '');
|
|
257
|
+
if (!text.trim()) {
|
|
258
|
+
text = '{\n}\n';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Ensure parent path exists (create missing parents as empty objects via parse+rewrite fallback)
|
|
262
|
+
let parentOpen = findObjectOpen(text, parentPath);
|
|
263
|
+
let createdParent = false;
|
|
264
|
+
if (parentOpen < 0) {
|
|
265
|
+
// Fall back: parse, ensure path, re-serialize whole doc (comments lost only when parent missing)
|
|
266
|
+
let doc;
|
|
267
|
+
try {
|
|
268
|
+
doc = parseJsonc(text);
|
|
269
|
+
} catch {
|
|
270
|
+
doc = {};
|
|
271
|
+
}
|
|
272
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) doc = {};
|
|
273
|
+
let cursor = doc;
|
|
274
|
+
for (const p of parentPath) {
|
|
275
|
+
if (!cursor[p] || typeof cursor[p] !== 'object' || Array.isArray(cursor[p])) {
|
|
276
|
+
cursor[p] = {};
|
|
277
|
+
createdParent = true;
|
|
278
|
+
}
|
|
279
|
+
cursor = cursor[p];
|
|
280
|
+
}
|
|
281
|
+
cursor[key] = valueObject;
|
|
282
|
+
return {
|
|
283
|
+
text: `${JSON.stringify(doc, null, indent)}\n`,
|
|
284
|
+
replaced: false,
|
|
285
|
+
createdParent: true,
|
|
286
|
+
commentsPreserved: false,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const valueStart = findPropertyValueStart(text, parentOpen, key);
|
|
291
|
+
const pretty = JSON.stringify(valueObject, null, indent);
|
|
292
|
+
// Reindent relative to parent: detect parent indent
|
|
293
|
+
const lineStart = text.lastIndexOf('\n', parentOpen) + 1;
|
|
294
|
+
const parentIndent = text.slice(lineStart, parentOpen).match(/^\s*/)[0];
|
|
295
|
+
const childIndent = parentIndent + ' '.repeat(indent);
|
|
296
|
+
const indentedValue = pretty
|
|
297
|
+
.split('\n')
|
|
298
|
+
.map((line, idx) => (idx === 0 ? line : childIndent + line))
|
|
299
|
+
.join('\n');
|
|
300
|
+
|
|
301
|
+
if (valueStart >= 0) {
|
|
302
|
+
const valueEnd = skipJsonValue(text, valueStart);
|
|
303
|
+
const next = `${text.slice(0, valueStart)}${indentedValue}${text.slice(valueEnd)}`;
|
|
304
|
+
return { text: next, replaced: true, createdParent, commentsPreserved: true };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Insert before closing brace of parent
|
|
308
|
+
const parentEnd = findMatchingBrace(text, parentOpen);
|
|
309
|
+
if (parentEnd < 0) {
|
|
310
|
+
throw new Error('unterminated JSON object while merging');
|
|
311
|
+
}
|
|
312
|
+
// Look at content between open and close for trailing comma needs
|
|
313
|
+
const inner = text.slice(parentOpen + 1, parentEnd);
|
|
314
|
+
const innerTrim = inner.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '').trim();
|
|
315
|
+
const needsComma = innerTrim.length > 0 && !innerTrim.endsWith(',');
|
|
316
|
+
const insertion = `${needsComma ? ',' : ''}\n${childIndent}"${key}": ${indentedValue}\n${parentIndent}`;
|
|
317
|
+
// If inner ends with whitespace before }, place insertion cleanly
|
|
318
|
+
let insertAt = parentEnd;
|
|
319
|
+
// trim trailing whitespace inside parent before insert
|
|
320
|
+
while (insertAt > parentOpen + 1 && /[ \t]/.test(text[insertAt - 1])) insertAt -= 1;
|
|
321
|
+
if (text[insertAt - 1] === '\n') {
|
|
322
|
+
// keep
|
|
323
|
+
}
|
|
324
|
+
const next = `${text.slice(0, insertAt)}${insertion}${text.slice(parentEnd)}`;
|
|
325
|
+
return { text: next, replaced: false, createdParent, commentsPreserved: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
stripJsonc,
|
|
330
|
+
parseJsonc,
|
|
331
|
+
findMatchingBrace,
|
|
332
|
+
findObjectOpen,
|
|
333
|
+
upsertJsoncProperty,
|
|
334
|
+
};
|
|
@@ -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 };
|