@te-river/opencode-team-mode 1.7.7 → 1.7.8
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 +1010 -1010
- package/README.zh-CN.md +907 -907
- package/dist/host/v2-browser-gate.js +2 -2
- package/dist/host/v2-offload.js +6 -6
- package/dist/host/v2-retry.d.ts +9 -1
- package/dist/host/v2-retry.d.ts.map +1 -1
- package/dist/host/v2-retry.js +10 -2
- package/dist/host/v2-retry.js.map +1 -1
- package/dist/prompts/specialists.js +286 -286
- package/dist/task-offload.js +1 -1
- package/dist/tm/config-layers.d.ts +5 -2
- package/dist/tm/config-layers.d.ts.map +1 -1
- package/dist/tm/config-layers.js +9 -6
- package/dist/tm/config-layers.js.map +1 -1
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +7 -1
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/ledger.js +5 -5
- package/dist/tm/memory.js +9 -9
- package/dist/tm/search.d.ts +3 -0
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +67 -15
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/tools.js +3 -3
- package/dist/tm/webfetch.d.ts +94 -5
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +285 -64
- package/dist/tm/webfetch.js.map +1 -1
- package/docs/installation.md +505 -505
- package/docs/research/agent-data-exchange.md +117 -117
- package/docs/research/browser-pane.md +94 -94
- package/docs/research/host-http-api.md +132 -132
- package/docs/research/host-subagent-injection.md +187 -187
- package/docs/research/plugin-loader-contract.md +261 -261
- package/docs/research/v2-builtin-tools.md +447 -447
- package/docs/v2-port-notes.md +121 -78
- package/index.js +7 -7
- package/package.json +64 -64
- package/scripts/lib/config-surgery.cjs +239 -239
|
@@ -1,239 +1,239 @@
|
|
|
1
|
-
// 2.x config surgery, shared by scripts/install.sh and scripts/install.ps1 so the two
|
|
2
|
-
// installers cannot drift into two different ideas of what "installed" means.
|
|
3
|
-
//
|
|
4
|
-
// Adds the plugin entry and/or sets default_agent, then READS THE VALUE BACK from disk
|
|
5
|
-
// before reporting success. Comments are masked to spaces at identical offsets, so a
|
|
6
|
-
// commented-out key can never be mistaken for a live one (the read-back would pass
|
|
7
|
-
// while the config said nothing).
|
|
8
|
-
//
|
|
9
|
-
// Why the key is `plugins` and not `plugin`, why one entry may not sit in two files,
|
|
10
|
-
// and why a directory target needs a root index.js: docs/research/plugin-loader-contract.md
|
|
11
|
-
// usage: node config-surgery.cjs <file> <plugins|default-agent> <value> [otherFileToScrub]
|
|
12
|
-
const fs = require("fs");
|
|
13
|
-
const [file, mode, value, other] = process.argv.slice(2);
|
|
14
|
-
if (!file || !mode || !value) {
|
|
15
|
-
console.error("ERR usage: node this.js <file> <plugins|default-agent> <value> [otherFileToScrub]");
|
|
16
|
-
process.exit(1);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const mask = (s) => {
|
|
20
|
-
let out = "", i = 0, inStr = false, esc = false;
|
|
21
|
-
while (i < s.length) {
|
|
22
|
-
const c = s[i];
|
|
23
|
-
if (inStr) {
|
|
24
|
-
out += c;
|
|
25
|
-
if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false;
|
|
26
|
-
i++; continue;
|
|
27
|
-
}
|
|
28
|
-
if (c === '"') { inStr = true; out += c; i++; continue; }
|
|
29
|
-
if (c === "/" && s[i + 1] === "/") { while (i < s.length && s[i] !== "\n") { out += " "; i++; } continue; }
|
|
30
|
-
if (c === "/" && s[i + 1] === "*") {
|
|
31
|
-
while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) { out += s[i] === "\n" ? "\n" : " "; i++; }
|
|
32
|
-
out += " "; i += 2; continue;
|
|
33
|
-
}
|
|
34
|
-
out += c; i++;
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const lineOf = (s, idx) => s.slice(0, idx).split("\n").length;
|
|
40
|
-
let src = fs.readFileSync(file, "utf8");
|
|
41
|
-
const open = mask(src).indexOf("{");
|
|
42
|
-
if (open < 0) { console.error("ERR no top-level object in " + file); process.exit(1); }
|
|
43
|
-
const nextNonWs = (s, i) => { while (i < s.length && /\s/.test(s[i])) i++; return i; };
|
|
44
|
-
/** insert one member right after the object's opening brace (top level by
|
|
45
|
-
* definition: in a JSONC config the first '{' IS the document's own). */
|
|
46
|
-
const insertMember = (s, member) => {
|
|
47
|
-
const after = nextNonWs(s, open + 1);
|
|
48
|
-
const comma = s[after] === "}" ? "" : ","; // an empty object takes none
|
|
49
|
-
return s.slice(0, open + 1) + "\n " + member + comma + s.slice(open + 1);
|
|
50
|
-
};
|
|
51
|
-
const lineNo = (s, idx) => " (line " + lineOf(s, idx) + " of " + file + ")";
|
|
52
|
-
|
|
53
|
-
if (mode === "plugins") {
|
|
54
|
-
// OpenCode 2.x reads `config.plugins` (PLURAL). The singular `plugin` key is the
|
|
55
|
-
// 1.18.x spelling; a 2.x host never reads it, so an entry written there looks like
|
|
56
|
-
// an installed plugin and is nothing. It is left in place (a user may still share
|
|
57
|
-
// the file with a 1.18.x install) but REPORTED.
|
|
58
|
-
//
|
|
59
|
-
// Exactly ONE entry for our package, in either spelling: the host loads every entry
|
|
60
|
-
// it finds, and the same plugin loaded twice means two personalities registering the
|
|
61
|
-
// same tools and hooks. So a previous entry of OURS (npm specifier vs ./vendor path)
|
|
62
|
-
// is REPLACED, never duplicated; entries naming anything else are untouched.
|
|
63
|
-
const lit = JSON.stringify(value);
|
|
64
|
-
// Case-insensitive, hyphen-optional and slash-tolerant, because a local install is
|
|
65
|
-
// spelled with the working tree's own directory name (`D:/Github/Opencode-TeamMode`,
|
|
66
|
-
// no hyphen between team and mode) while the npm entry is `opencode-team-mode`. An
|
|
67
|
-
// entry we fail to recognise as ours is one we would ADD next to the existing one —
|
|
68
|
-
// and two entries is two loads, which is the defect this function exists to prevent.
|
|
69
|
-
const ours = (t) => /opencode[-_]?team[-_]?mode|vendor[/\\]team-mode/i.test(t);
|
|
70
|
-
const findArray = (mk, key) => {
|
|
71
|
-
const m = new RegExp('"' + key + '"\\s*:\\s*\\[').exec(mk);
|
|
72
|
-
if (!m) return null;
|
|
73
|
-
let i = m.index + m[0].length, depth = 1;
|
|
74
|
-
while (i < mk.length) {
|
|
75
|
-
const c = mk[i];
|
|
76
|
-
if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0) return null; i = j + 1; continue; }
|
|
77
|
-
if (c === "[" || c === "{") depth++;
|
|
78
|
-
else if (c === "]" || c === "}") { depth--; if (depth === 0) return { head: m.index + m[0].length, end: i }; }
|
|
79
|
-
i++;
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
};
|
|
83
|
-
/** [start,end) spans of the array's own elements in [head,end); commas and whitespace
|
|
84
|
-
* are skipped, so a trailing comma — which a hand-edited or host-written config often
|
|
85
|
-
* carries — cannot end up producing ", ,", which would break the parse. */
|
|
86
|
-
const elements = (mk, head, end) => {
|
|
87
|
-
const out = []; let i = head;
|
|
88
|
-
while (i < end) {
|
|
89
|
-
const c = mk[i];
|
|
90
|
-
if (c === "," || /\s/.test(c)) { i++; continue; }
|
|
91
|
-
if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0 || j >= end) break; out.push([i, j + 1]); i = j + 1; continue; }
|
|
92
|
-
if (c === "{" || c === "[") {
|
|
93
|
-
let d = 0, k = i;
|
|
94
|
-
for (; k < end; k++) {
|
|
95
|
-
const q = mk[k];
|
|
96
|
-
if (q === '"') { k = mk.indexOf('"', k + 1); if (k < 0 || k >= end) { k = end; break } continue; }
|
|
97
|
-
if (q === "{" || q === "[") d++;
|
|
98
|
-
else if (q === "}" || q === "]") { d--; if (!d) { k++; break; } }
|
|
99
|
-
}
|
|
100
|
-
out.push([i, k]); i = k; continue;
|
|
101
|
-
}
|
|
102
|
-
let k = i; while (k < end && !/[,}\]\s]/.test(mk[k])) k++;
|
|
103
|
-
out.push([i, k]); i = k;
|
|
104
|
-
}
|
|
105
|
-
return out;
|
|
106
|
-
};
|
|
107
|
-
const view = (text) => { const mk = mask(text); const a = findArray(mk, "plugins"); return { mk, a, els: a ? elements(mk, a.head, a.end) : [] }; };
|
|
108
|
-
|
|
109
|
-
const v0 = view(src);
|
|
110
|
-
if (findArray(v0.mk, "plugin")) {
|
|
111
|
-
console.log("NOTE this file also has a top-level \"plugin\" (singular) key — OpenCode 2.x loads" +
|
|
112
|
-
"\n config.plugins (plural) and never reads the singular one. Left untouched; if 1.18.x\n" +
|
|
113
|
-
" is gone from this machine, the key and its entries are dead weight you can delete.");
|
|
114
|
-
}
|
|
115
|
-
if (!v0.a) {
|
|
116
|
-
fs.writeFileSync(file, insertMember(src, '"plugins": [\n ' + lit + "\n ]"));
|
|
117
|
-
console.log("OK created the plugins array with " + lit);
|
|
118
|
-
} else {
|
|
119
|
-
const same = v0.els.filter(([a, b]) => v0.mk.slice(a, b) === lit);
|
|
120
|
-
const dupe = v0.els.filter(([a, b]) => ours(v0.mk.slice(a, b)) && v0.mk.slice(a, b) !== lit);
|
|
121
|
-
if (dupe.length) {
|
|
122
|
-
// Replace the first, drop the rest: two entries of ours = two loaded personalities.
|
|
123
|
-
let out = src;
|
|
124
|
-
for (let n = dupe.length - 1; n >= 0; n--) {
|
|
125
|
-
const [a, b] = dupe[n];
|
|
126
|
-
out = n === 0 ? out.slice(0, a) + lit + out.slice(b) : out.slice(0, a) + out.slice(b);
|
|
127
|
-
}
|
|
128
|
-
fs.writeFileSync(file, out);
|
|
129
|
-
console.log("OK replaced " + dupe.length + " previous Team entry/entries with " + lit);
|
|
130
|
-
} else if (same.length) {
|
|
131
|
-
console.log("OK plugins already lists " + lit +
|
|
132
|
-
(same.length > 1 ? " — " + same.length + " times, which loads the plugin twice" : ""));
|
|
133
|
-
} else {
|
|
134
|
-
const last = v0.els[v0.els.length - 1];
|
|
135
|
-
const at = last ? last[1] : v0.a.head;
|
|
136
|
-
const pad = last ? ",\n " : "\n ";
|
|
137
|
-
fs.writeFileSync(file, src.slice(0, at) + pad + lit + src.slice(at));
|
|
138
|
-
console.log("OK added " + lit + " alongside " + v0.els.length + " existing entr" + (v0.els.length === 1 ? "y" : "ies"));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
// Read it back off the disk. The host's loader skips a plugin directory whose
|
|
142
|
-
// entrypoint it cannot resolve with NO message at all, and a key nobody reads is
|
|
143
|
-
// indistinguishable from an install — so the only claim made here is the measured one.
|
|
144
|
-
const v1 = view(fs.readFileSync(file, "utf8"));
|
|
145
|
-
const got = v1.els.filter(([a, b]) => v1.mk.slice(a, b) === lit).length;
|
|
146
|
-
if (!got) {
|
|
147
|
-
console.error("ERR read-back: plugins does not hold " + lit + " (array " + (v1.a ? "present" : "ABSENT") + ")");
|
|
148
|
-
console.error(" Add it by hand at the top level of " + file + ": \"plugins\": [" + lit + "]");
|
|
149
|
-
process.exit(1);
|
|
150
|
-
}
|
|
151
|
-
const stillOurs = v1.els.filter(([a, b]) => ours(v1.mk.slice(a, b))).length;
|
|
152
|
-
console.log("OK read back: plugins holds " + lit + " (" + v1.els.length + " entries, " +
|
|
153
|
-
stillOurs + " of them Team)");
|
|
154
|
-
if (got > 1 || stillOurs > 1) {
|
|
155
|
-
console.error("ERR the array carries our package more than once — the host would load it twice");
|
|
156
|
-
process.exit(1);
|
|
157
|
-
}
|
|
158
|
-
// Reclaim our entry from the OTHER global config file. The two files are merged at
|
|
159
|
-
// parse time and the host dedupes only on an identical string, so leaving our entry
|
|
160
|
-
// in opencode.json while writing this one would register the plugin twice — two
|
|
161
|
-
// personalities, the same tools and hooks bound twice, and a boot log that reads as
|
|
162
|
-
// one healthy install. Foreign entries (another plugin, any other key) are untouched.
|
|
163
|
-
if (other && other !== file && fs.existsSync(other)) {
|
|
164
|
-
const osrc = fs.readFileSync(other, "utf8");
|
|
165
|
-
const ov = view(osrc);
|
|
166
|
-
const mine = ov.els.filter(([a, b]) => ours(ov.mk.slice(a, b)));
|
|
167
|
-
if (mine.length) {
|
|
168
|
-
let out = osrc;
|
|
169
|
-
for (let n = mine.length - 1; n >= 0; n--) {
|
|
170
|
-
const [a, b] = mine[n];
|
|
171
|
-
// Take the comma with the element, and pick the RIGHT side: dropping a trailing
|
|
172
|
-
// comma when the element is the last one would leave `"x",\n ]`, which only
|
|
173
|
-
// parses because the host reads JSONC with allowTrailingComma — and a config that
|
|
174
|
-
// is valid solely under a lenient parser is a trap for the next hand edit.
|
|
175
|
-
let prev = a - 1;
|
|
176
|
-
while (prev >= 0 && /\s/.test(out[prev])) prev--;
|
|
177
|
-
if (prev >= 0 && out[prev] === ",") {
|
|
178
|
-
out = out.slice(0, prev) + out.slice(b); // not first: remove prev comma + element
|
|
179
|
-
} else {
|
|
180
|
-
let next = b;
|
|
181
|
-
while (next < out.length && /\s/.test(out[next])) next++;
|
|
182
|
-
out = out.slice(0, a) + out.slice(out[next] === "," ? next + 1 : b); // first: remove element + its comma
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
fs.writeFileSync(other, out);
|
|
186
|
-
// [Q, T,] with T removed keeps a comma either way (a separator on one side, a
|
|
187
|
-
// trailing one on the other), so a single element + comma delete cannot produce
|
|
188
|
-
// strict JSON. Strip a comma that now dangles immediately before THIS array's own
|
|
189
|
-
// closing bracket — that region and no other, because the rest of the file is not
|
|
190
|
-
// ours to reformat.
|
|
191
|
-
const after = view(fs.readFileSync(other, "utf8"));
|
|
192
|
-
if (after.a) {
|
|
193
|
-
let p = after.a.end - 1;
|
|
194
|
-
while (p > after.a.head && /\s/.test(out[p])) p--;
|
|
195
|
-
if (out[p] === ",") {
|
|
196
|
-
out = out.slice(0, p) + out.slice(p + 1);
|
|
197
|
-
fs.writeFileSync(other, out);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
const chk = view(fs.readFileSync(other, "utf8"));
|
|
201
|
-
const left = chk.els.filter(([a, b]) => ours(chk.mk.slice(a, b))).length;
|
|
202
|
-
if (left) {
|
|
203
|
-
console.error("ERR " + other + " still lists Team after the reclaim — the plugin would load twice");
|
|
204
|
-
process.exit(1);
|
|
205
|
-
}
|
|
206
|
-
console.log("OK reclaimed " + mine.length + " Team entr" + (mine.length === 1 ? "y" : "ies") +
|
|
207
|
-
" from " + other + " (a second copy means a second load)");
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
} else if (mode === "default-agent") {
|
|
211
|
-
const lit = JSON.stringify(value);
|
|
212
|
-
const re = /"default_agent"(\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s][^,}\n]*)/;
|
|
213
|
-
const m = re.exec(mask(src));
|
|
214
|
-
if (m) {
|
|
215
|
-
const vStart = m.index + m[0].length - m[2].length;
|
|
216
|
-
if (m[2].trim() === lit) {
|
|
217
|
-
console.log("OK default_agent already " + lit + lineNo(src, m.index));
|
|
218
|
-
} else {
|
|
219
|
-
fs.writeFileSync(file, src.slice(0, vStart) + lit + src.slice(vStart + m[2].length));
|
|
220
|
-
console.log("OK default_agent rewritten (was " + m[2].trim() + ")");
|
|
221
|
-
}
|
|
222
|
-
} else {
|
|
223
|
-
fs.writeFileSync(file, insertMember(src, '"default_agent": ' + lit));
|
|
224
|
-
console.log("OK default_agent added");
|
|
225
|
-
}
|
|
226
|
-
// Read it back — a fresh read of the file from disk, comments masked again.
|
|
227
|
-
src = fs.readFileSync(file, "utf8");
|
|
228
|
-
const chk = re.exec(mask(src));
|
|
229
|
-
const got = chk ? chk[2].trim() : null;
|
|
230
|
-
if (got !== lit) {
|
|
231
|
-
console.error("ERR default_agent read back as " + (got === null ? "(key absent)" : got) + ", not " + lit);
|
|
232
|
-
console.error(" Add it by hand at the top level of " + file + ": \"default_agent\": " + lit);
|
|
233
|
-
process.exit(1);
|
|
234
|
-
}
|
|
235
|
-
console.log("OK read back: " + lit + lineNo(src, chk.index));
|
|
236
|
-
} else {
|
|
237
|
-
console.error("ERR unknown mode '" + mode + "'");
|
|
238
|
-
process.exit(1);
|
|
239
|
-
}
|
|
1
|
+
// 2.x config surgery, shared by scripts/install.sh and scripts/install.ps1 so the two
|
|
2
|
+
// installers cannot drift into two different ideas of what "installed" means.
|
|
3
|
+
//
|
|
4
|
+
// Adds the plugin entry and/or sets default_agent, then READS THE VALUE BACK from disk
|
|
5
|
+
// before reporting success. Comments are masked to spaces at identical offsets, so a
|
|
6
|
+
// commented-out key can never be mistaken for a live one (the read-back would pass
|
|
7
|
+
// while the config said nothing).
|
|
8
|
+
//
|
|
9
|
+
// Why the key is `plugins` and not `plugin`, why one entry may not sit in two files,
|
|
10
|
+
// and why a directory target needs a root index.js: docs/research/plugin-loader-contract.md
|
|
11
|
+
// usage: node config-surgery.cjs <file> <plugins|default-agent> <value> [otherFileToScrub]
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const [file, mode, value, other] = process.argv.slice(2);
|
|
14
|
+
if (!file || !mode || !value) {
|
|
15
|
+
console.error("ERR usage: node this.js <file> <plugins|default-agent> <value> [otherFileToScrub]");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mask = (s) => {
|
|
20
|
+
let out = "", i = 0, inStr = false, esc = false;
|
|
21
|
+
while (i < s.length) {
|
|
22
|
+
const c = s[i];
|
|
23
|
+
if (inStr) {
|
|
24
|
+
out += c;
|
|
25
|
+
if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false;
|
|
26
|
+
i++; continue;
|
|
27
|
+
}
|
|
28
|
+
if (c === '"') { inStr = true; out += c; i++; continue; }
|
|
29
|
+
if (c === "/" && s[i + 1] === "/") { while (i < s.length && s[i] !== "\n") { out += " "; i++; } continue; }
|
|
30
|
+
if (c === "/" && s[i + 1] === "*") {
|
|
31
|
+
while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) { out += s[i] === "\n" ? "\n" : " "; i++; }
|
|
32
|
+
out += " "; i += 2; continue;
|
|
33
|
+
}
|
|
34
|
+
out += c; i++;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const lineOf = (s, idx) => s.slice(0, idx).split("\n").length;
|
|
40
|
+
let src = fs.readFileSync(file, "utf8");
|
|
41
|
+
const open = mask(src).indexOf("{");
|
|
42
|
+
if (open < 0) { console.error("ERR no top-level object in " + file); process.exit(1); }
|
|
43
|
+
const nextNonWs = (s, i) => { while (i < s.length && /\s/.test(s[i])) i++; return i; };
|
|
44
|
+
/** insert one member right after the object's opening brace (top level by
|
|
45
|
+
* definition: in a JSONC config the first '{' IS the document's own). */
|
|
46
|
+
const insertMember = (s, member) => {
|
|
47
|
+
const after = nextNonWs(s, open + 1);
|
|
48
|
+
const comma = s[after] === "}" ? "" : ","; // an empty object takes none
|
|
49
|
+
return s.slice(0, open + 1) + "\n " + member + comma + s.slice(open + 1);
|
|
50
|
+
};
|
|
51
|
+
const lineNo = (s, idx) => " (line " + lineOf(s, idx) + " of " + file + ")";
|
|
52
|
+
|
|
53
|
+
if (mode === "plugins") {
|
|
54
|
+
// OpenCode 2.x reads `config.plugins` (PLURAL). The singular `plugin` key is the
|
|
55
|
+
// 1.18.x spelling; a 2.x host never reads it, so an entry written there looks like
|
|
56
|
+
// an installed plugin and is nothing. It is left in place (a user may still share
|
|
57
|
+
// the file with a 1.18.x install) but REPORTED.
|
|
58
|
+
//
|
|
59
|
+
// Exactly ONE entry for our package, in either spelling: the host loads every entry
|
|
60
|
+
// it finds, and the same plugin loaded twice means two personalities registering the
|
|
61
|
+
// same tools and hooks. So a previous entry of OURS (npm specifier vs ./vendor path)
|
|
62
|
+
// is REPLACED, never duplicated; entries naming anything else are untouched.
|
|
63
|
+
const lit = JSON.stringify(value);
|
|
64
|
+
// Case-insensitive, hyphen-optional and slash-tolerant, because a local install is
|
|
65
|
+
// spelled with the working tree's own directory name (`D:/Github/Opencode-TeamMode`,
|
|
66
|
+
// no hyphen between team and mode) while the npm entry is `opencode-team-mode`. An
|
|
67
|
+
// entry we fail to recognise as ours is one we would ADD next to the existing one —
|
|
68
|
+
// and two entries is two loads, which is the defect this function exists to prevent.
|
|
69
|
+
const ours = (t) => /opencode[-_]?team[-_]?mode|vendor[/\\]team-mode/i.test(t);
|
|
70
|
+
const findArray = (mk, key) => {
|
|
71
|
+
const m = new RegExp('"' + key + '"\\s*:\\s*\\[').exec(mk);
|
|
72
|
+
if (!m) return null;
|
|
73
|
+
let i = m.index + m[0].length, depth = 1;
|
|
74
|
+
while (i < mk.length) {
|
|
75
|
+
const c = mk[i];
|
|
76
|
+
if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0) return null; i = j + 1; continue; }
|
|
77
|
+
if (c === "[" || c === "{") depth++;
|
|
78
|
+
else if (c === "]" || c === "}") { depth--; if (depth === 0) return { head: m.index + m[0].length, end: i }; }
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
/** [start,end) spans of the array's own elements in [head,end); commas and whitespace
|
|
84
|
+
* are skipped, so a trailing comma — which a hand-edited or host-written config often
|
|
85
|
+
* carries — cannot end up producing ", ,", which would break the parse. */
|
|
86
|
+
const elements = (mk, head, end) => {
|
|
87
|
+
const out = []; let i = head;
|
|
88
|
+
while (i < end) {
|
|
89
|
+
const c = mk[i];
|
|
90
|
+
if (c === "," || /\s/.test(c)) { i++; continue; }
|
|
91
|
+
if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0 || j >= end) break; out.push([i, j + 1]); i = j + 1; continue; }
|
|
92
|
+
if (c === "{" || c === "[") {
|
|
93
|
+
let d = 0, k = i;
|
|
94
|
+
for (; k < end; k++) {
|
|
95
|
+
const q = mk[k];
|
|
96
|
+
if (q === '"') { k = mk.indexOf('"', k + 1); if (k < 0 || k >= end) { k = end; break } continue; }
|
|
97
|
+
if (q === "{" || q === "[") d++;
|
|
98
|
+
else if (q === "}" || q === "]") { d--; if (!d) { k++; break; } }
|
|
99
|
+
}
|
|
100
|
+
out.push([i, k]); i = k; continue;
|
|
101
|
+
}
|
|
102
|
+
let k = i; while (k < end && !/[,}\]\s]/.test(mk[k])) k++;
|
|
103
|
+
out.push([i, k]); i = k;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
};
|
|
107
|
+
const view = (text) => { const mk = mask(text); const a = findArray(mk, "plugins"); return { mk, a, els: a ? elements(mk, a.head, a.end) : [] }; };
|
|
108
|
+
|
|
109
|
+
const v0 = view(src);
|
|
110
|
+
if (findArray(v0.mk, "plugin")) {
|
|
111
|
+
console.log("NOTE this file also has a top-level \"plugin\" (singular) key — OpenCode 2.x loads" +
|
|
112
|
+
"\n config.plugins (plural) and never reads the singular one. Left untouched; if 1.18.x\n" +
|
|
113
|
+
" is gone from this machine, the key and its entries are dead weight you can delete.");
|
|
114
|
+
}
|
|
115
|
+
if (!v0.a) {
|
|
116
|
+
fs.writeFileSync(file, insertMember(src, '"plugins": [\n ' + lit + "\n ]"));
|
|
117
|
+
console.log("OK created the plugins array with " + lit);
|
|
118
|
+
} else {
|
|
119
|
+
const same = v0.els.filter(([a, b]) => v0.mk.slice(a, b) === lit);
|
|
120
|
+
const dupe = v0.els.filter(([a, b]) => ours(v0.mk.slice(a, b)) && v0.mk.slice(a, b) !== lit);
|
|
121
|
+
if (dupe.length) {
|
|
122
|
+
// Replace the first, drop the rest: two entries of ours = two loaded personalities.
|
|
123
|
+
let out = src;
|
|
124
|
+
for (let n = dupe.length - 1; n >= 0; n--) {
|
|
125
|
+
const [a, b] = dupe[n];
|
|
126
|
+
out = n === 0 ? out.slice(0, a) + lit + out.slice(b) : out.slice(0, a) + out.slice(b);
|
|
127
|
+
}
|
|
128
|
+
fs.writeFileSync(file, out);
|
|
129
|
+
console.log("OK replaced " + dupe.length + " previous Team entry/entries with " + lit);
|
|
130
|
+
} else if (same.length) {
|
|
131
|
+
console.log("OK plugins already lists " + lit +
|
|
132
|
+
(same.length > 1 ? " — " + same.length + " times, which loads the plugin twice" : ""));
|
|
133
|
+
} else {
|
|
134
|
+
const last = v0.els[v0.els.length - 1];
|
|
135
|
+
const at = last ? last[1] : v0.a.head;
|
|
136
|
+
const pad = last ? ",\n " : "\n ";
|
|
137
|
+
fs.writeFileSync(file, src.slice(0, at) + pad + lit + src.slice(at));
|
|
138
|
+
console.log("OK added " + lit + " alongside " + v0.els.length + " existing entr" + (v0.els.length === 1 ? "y" : "ies"));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Read it back off the disk. The host's loader skips a plugin directory whose
|
|
142
|
+
// entrypoint it cannot resolve with NO message at all, and a key nobody reads is
|
|
143
|
+
// indistinguishable from an install — so the only claim made here is the measured one.
|
|
144
|
+
const v1 = view(fs.readFileSync(file, "utf8"));
|
|
145
|
+
const got = v1.els.filter(([a, b]) => v1.mk.slice(a, b) === lit).length;
|
|
146
|
+
if (!got) {
|
|
147
|
+
console.error("ERR read-back: plugins does not hold " + lit + " (array " + (v1.a ? "present" : "ABSENT") + ")");
|
|
148
|
+
console.error(" Add it by hand at the top level of " + file + ": \"plugins\": [" + lit + "]");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
const stillOurs = v1.els.filter(([a, b]) => ours(v1.mk.slice(a, b))).length;
|
|
152
|
+
console.log("OK read back: plugins holds " + lit + " (" + v1.els.length + " entries, " +
|
|
153
|
+
stillOurs + " of them Team)");
|
|
154
|
+
if (got > 1 || stillOurs > 1) {
|
|
155
|
+
console.error("ERR the array carries our package more than once — the host would load it twice");
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
// Reclaim our entry from the OTHER global config file. The two files are merged at
|
|
159
|
+
// parse time and the host dedupes only on an identical string, so leaving our entry
|
|
160
|
+
// in opencode.json while writing this one would register the plugin twice — two
|
|
161
|
+
// personalities, the same tools and hooks bound twice, and a boot log that reads as
|
|
162
|
+
// one healthy install. Foreign entries (another plugin, any other key) are untouched.
|
|
163
|
+
if (other && other !== file && fs.existsSync(other)) {
|
|
164
|
+
const osrc = fs.readFileSync(other, "utf8");
|
|
165
|
+
const ov = view(osrc);
|
|
166
|
+
const mine = ov.els.filter(([a, b]) => ours(ov.mk.slice(a, b)));
|
|
167
|
+
if (mine.length) {
|
|
168
|
+
let out = osrc;
|
|
169
|
+
for (let n = mine.length - 1; n >= 0; n--) {
|
|
170
|
+
const [a, b] = mine[n];
|
|
171
|
+
// Take the comma with the element, and pick the RIGHT side: dropping a trailing
|
|
172
|
+
// comma when the element is the last one would leave `"x",\n ]`, which only
|
|
173
|
+
// parses because the host reads JSONC with allowTrailingComma — and a config that
|
|
174
|
+
// is valid solely under a lenient parser is a trap for the next hand edit.
|
|
175
|
+
let prev = a - 1;
|
|
176
|
+
while (prev >= 0 && /\s/.test(out[prev])) prev--;
|
|
177
|
+
if (prev >= 0 && out[prev] === ",") {
|
|
178
|
+
out = out.slice(0, prev) + out.slice(b); // not first: remove prev comma + element
|
|
179
|
+
} else {
|
|
180
|
+
let next = b;
|
|
181
|
+
while (next < out.length && /\s/.test(out[next])) next++;
|
|
182
|
+
out = out.slice(0, a) + out.slice(out[next] === "," ? next + 1 : b); // first: remove element + its comma
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
fs.writeFileSync(other, out);
|
|
186
|
+
// [Q, T,] with T removed keeps a comma either way (a separator on one side, a
|
|
187
|
+
// trailing one on the other), so a single element + comma delete cannot produce
|
|
188
|
+
// strict JSON. Strip a comma that now dangles immediately before THIS array's own
|
|
189
|
+
// closing bracket — that region and no other, because the rest of the file is not
|
|
190
|
+
// ours to reformat.
|
|
191
|
+
const after = view(fs.readFileSync(other, "utf8"));
|
|
192
|
+
if (after.a) {
|
|
193
|
+
let p = after.a.end - 1;
|
|
194
|
+
while (p > after.a.head && /\s/.test(out[p])) p--;
|
|
195
|
+
if (out[p] === ",") {
|
|
196
|
+
out = out.slice(0, p) + out.slice(p + 1);
|
|
197
|
+
fs.writeFileSync(other, out);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const chk = view(fs.readFileSync(other, "utf8"));
|
|
201
|
+
const left = chk.els.filter(([a, b]) => ours(chk.mk.slice(a, b))).length;
|
|
202
|
+
if (left) {
|
|
203
|
+
console.error("ERR " + other + " still lists Team after the reclaim — the plugin would load twice");
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
console.log("OK reclaimed " + mine.length + " Team entr" + (mine.length === 1 ? "y" : "ies") +
|
|
207
|
+
" from " + other + " (a second copy means a second load)");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} else if (mode === "default-agent") {
|
|
211
|
+
const lit = JSON.stringify(value);
|
|
212
|
+
const re = /"default_agent"(\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s][^,}\n]*)/;
|
|
213
|
+
const m = re.exec(mask(src));
|
|
214
|
+
if (m) {
|
|
215
|
+
const vStart = m.index + m[0].length - m[2].length;
|
|
216
|
+
if (m[2].trim() === lit) {
|
|
217
|
+
console.log("OK default_agent already " + lit + lineNo(src, m.index));
|
|
218
|
+
} else {
|
|
219
|
+
fs.writeFileSync(file, src.slice(0, vStart) + lit + src.slice(vStart + m[2].length));
|
|
220
|
+
console.log("OK default_agent rewritten (was " + m[2].trim() + ")");
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
fs.writeFileSync(file, insertMember(src, '"default_agent": ' + lit));
|
|
224
|
+
console.log("OK default_agent added");
|
|
225
|
+
}
|
|
226
|
+
// Read it back — a fresh read of the file from disk, comments masked again.
|
|
227
|
+
src = fs.readFileSync(file, "utf8");
|
|
228
|
+
const chk = re.exec(mask(src));
|
|
229
|
+
const got = chk ? chk[2].trim() : null;
|
|
230
|
+
if (got !== lit) {
|
|
231
|
+
console.error("ERR default_agent read back as " + (got === null ? "(key absent)" : got) + ", not " + lit);
|
|
232
|
+
console.error(" Add it by hand at the top level of " + file + ": \"default_agent\": " + lit);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
console.log("OK read back: " + lit + lineNo(src, chk.index));
|
|
236
|
+
} else {
|
|
237
|
+
console.error("ERR unknown mode '" + mode + "'");
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|