@kairyou/agent-tools 0.4.0 → 0.5.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,186 @@
1
+ // JSON-over-HTTP requests to gateways, including the anti-bot shield
2
+ // challenge some NewAPI deployments serve before the real response.
3
+
4
+ import { createContext, runInContext } from "node:vm";
5
+ import { debugLog } from "./config.mjs";
6
+
7
+ const REQUEST_TIMEOUT_MS = 5000;
8
+ const SHIELD_USER_AGENT =
9
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
10
+ "(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
11
+
12
+ function shortPreview(text) {
13
+ return String(text || "")
14
+ .replace(/\s+/g, " ")
15
+ .trim()
16
+ .slice(0, 220);
17
+ }
18
+
19
+ function isShieldChallenge(contentType, text) {
20
+ const normalizedType = String(contentType || "").toLowerCase();
21
+ return (
22
+ (normalizedType.includes("text/html") && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) ||
23
+ /var\s+arg1\s*=/.test(text)
24
+ );
25
+ }
26
+
27
+ function parseChallengeArg1(html) {
28
+ const match = String(html).match(/var\s+arg1\s*=\s*['"]([0-9a-fA-F]+)['"]/);
29
+ return match?.[1]?.toUpperCase() || "";
30
+ }
31
+
32
+ function parseChallengeMapping(html) {
33
+ const match = String(html).match(/for\(var m=\[([^\]]+)\],p=L\(0x115\)/);
34
+ if (!match?.[1]) return null;
35
+ const values = match[1].split(",").map((raw) => {
36
+ const value = raw.trim().toLowerCase();
37
+ if (!value) return Number.NaN;
38
+ return value.startsWith("0x") ? Number.parseInt(value.slice(2), 16) : Number.parseInt(value, 10);
39
+ });
40
+ return values.some((value) => Number.isNaN(value)) ? null : values;
41
+ }
42
+
43
+ function parseChallengeXorSeed(html) {
44
+ const text = String(html);
45
+ const fnStart = text.indexOf("function a0i()");
46
+ const bStart = text.indexOf("function b(");
47
+ const rotateStart = text.indexOf("(function(a,c){");
48
+ const rotateEnd = text.indexOf("),!(function", rotateStart);
49
+ if (fnStart < 0 || bStart < 0 || bStart <= fnStart || rotateStart < 0 || rotateEnd < 0) {
50
+ return "";
51
+ }
52
+
53
+ const helperCode = text.slice(fnStart, bStart);
54
+ const rotateCode = `${text.slice(rotateStart, rotateEnd + 1)})`;
55
+ try {
56
+ const sandbox = { decodeURIComponent };
57
+ createContext(sandbox);
58
+ runInContext(helperCode, sandbox, { timeout: 100 });
59
+ runInContext(rotateCode, sandbox, { timeout: 100 });
60
+ const decoder = sandbox.a0j;
61
+ if (typeof decoder !== "function") return "";
62
+ const seed = decoder(0x115);
63
+ return typeof seed === "string" && /^[0-9a-f]+$/i.test(seed) ? seed : "";
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ function solveNewApiAcwScV2(html) {
70
+ const arg1 = parseChallengeArg1(html);
71
+ const mapping = parseChallengeMapping(html);
72
+ const xorSeed = parseChallengeXorSeed(html);
73
+ if (!arg1 || !mapping || !xorSeed) return "";
74
+
75
+ const reordered = [];
76
+ for (let i = 0; i < arg1.length; i += 1) {
77
+ const ch = arg1[i];
78
+ for (let j = 0; j < mapping.length; j += 1) {
79
+ if (mapping[j] === i + 1) reordered[j] = ch;
80
+ }
81
+ }
82
+
83
+ const source = reordered.join("");
84
+ let out = "";
85
+ for (let i = 0; i < source.length && i < xorSeed.length; i += 2) {
86
+ const left = Number.parseInt(source.slice(i, i + 2), 16);
87
+ const right = Number.parseInt(xorSeed.slice(i, i + 2), 16);
88
+ if (Number.isNaN(left) || Number.isNaN(right)) return "";
89
+ out += (left ^ right).toString(16).padStart(2, "0");
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function upsertCookie(cookieHeader, name, value) {
95
+ const parts = String(cookieHeader || "").split(";").map((part) => part.trim()).filter(Boolean);
96
+ let replaced = false;
97
+ const next = parts.map((part) => {
98
+ const eq = part.indexOf("=");
99
+ if (eq < 0) return part;
100
+ const key = part.slice(0, eq).trim();
101
+ if (key !== name) return part;
102
+ replaced = true;
103
+ return `${name}=${value}`;
104
+ });
105
+ if (!replaced) next.push(`${name}=${value}`);
106
+ return next.join("; ");
107
+ }
108
+
109
+ function collectSetCookieHeaders(headers) {
110
+ const getSetCookie = headers?.getSetCookie;
111
+ if (typeof getSetCookie === "function") return getSetCookie.call(headers) || [];
112
+ const single = headers?.get?.("set-cookie");
113
+ return single ? [single] : [];
114
+ }
115
+
116
+ function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
117
+ let merged = cookieHeader || "";
118
+ for (const raw of setCookieHeaders || []) {
119
+ const firstPair = String(raw || "").split(";")[0]?.trim();
120
+ if (!firstPair) continue;
121
+ const eq = firstPair.indexOf("=");
122
+ if (eq <= 0) continue;
123
+ merged = upsertCookie(merged, firstPair.slice(0, eq).trim(), firstPair.slice(eq + 1));
124
+ }
125
+ return merged;
126
+ }
127
+
128
+ export async function requestJson(url, key, options = {}) {
129
+ let cookieHeader = "";
130
+ for (let attempt = 0; attempt < 2; attempt += 1) {
131
+ const controller = new AbortController();
132
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
133
+ const response = await fetch(url, {
134
+ headers: {
135
+ accept: "application/json",
136
+ authorization: `Bearer ${options.authKey || key}`,
137
+ "user-agent": SHIELD_USER_AGENT,
138
+ ...(cookieHeader ? { cookie: cookieHeader } : {}),
139
+ ...(options.headers || {}),
140
+ },
141
+ signal: controller.signal,
142
+ });
143
+
144
+ try {
145
+ const body = await response.text();
146
+ cookieHeader = mergeSetCookiePairs(cookieHeader, collectSetCookieHeaders(response.headers));
147
+ let json = {};
148
+ try {
149
+ json = body ? JSON.parse(body) : {};
150
+ } catch {
151
+ const contentType = response.headers.get("content-type") || "";
152
+ const acwScV2 = isShieldChallenge(contentType, body) ? solveNewApiAcwScV2(body) : "";
153
+ await debugLog({
154
+ source: options.name || "usage",
155
+ url,
156
+ status: response.status,
157
+ contentType,
158
+ shieldRetry: Boolean(acwScV2 && attempt === 0),
159
+ bodyPreview: shortPreview(body),
160
+ });
161
+ if (acwScV2 && attempt === 0) {
162
+ cookieHeader = upsertCookie(cookieHeader, "acw_sc__v2", acwScV2);
163
+ continue;
164
+ }
165
+ throw new Error(`${options.name || "usage"} returned non-JSON (${response.status})`);
166
+ }
167
+
168
+ if (!response.ok) {
169
+ const message = json?.error?.message || json?.message || response.statusText;
170
+ await debugLog({
171
+ source: options.name || "usage",
172
+ url,
173
+ status: response.status,
174
+ message,
175
+ bodyPreview: shortPreview(body),
176
+ });
177
+ throw new Error(`${options.name || "usage"} failed (${response.status} ${message})`);
178
+ }
179
+
180
+ return json;
181
+ } finally {
182
+ clearTimeout(timeout);
183
+ }
184
+ }
185
+ throw new Error(`${options.name || "usage"} unavailable`);
186
+ }
@@ -0,0 +1,265 @@
1
+ // Known gateway usage endpoints and the probing order for a given context.
2
+
3
+ import { requestJson } from "./http.mjs";
4
+ import {
5
+ usagePreset,
6
+ panelUserHeaders,
7
+ newApiQuotaScale,
8
+ providerUsageDays,
9
+ debugLog,
10
+ } from "./config.mjs";
11
+ import {
12
+ cleanBaseUrl,
13
+ serviceRoot,
14
+ joinUrl,
15
+ hostIncludes,
16
+ usageRouteCacheKey,
17
+ } from "./urls.mjs";
18
+ import {
19
+ pickNumber,
20
+ formatMoney,
21
+ usageRoot,
22
+ hasV1UsageFields,
23
+ formatQuota,
24
+ formatNewApiTokenLine,
25
+ formatOpenRouterLine,
26
+ formatPanelUserSelfLine,
27
+ panelQuotaScale,
28
+ panelQuotaLooksRemaining,
29
+ } from "./format.mjs";
30
+ import { readRouteCache } from "./cache.mjs";
31
+
32
+ async function subscriptionUrl(baseUrl) {
33
+ const clean = baseUrl.replace(/\/+$/, "");
34
+ const url = clean.endsWith("/v1") ? `${clean}/usage` : `${clean}/v1/usage`;
35
+ return `${url}?days=${await providerUsageDays()}`;
36
+ }
37
+
38
+ function usageResult(context, source, text, raw) {
39
+ return {
40
+ updatedAt: new Date().toISOString(),
41
+ baseUrl: context.baseUrl,
42
+ provider: context.providerName,
43
+ source,
44
+ text,
45
+ raw,
46
+ };
47
+ }
48
+
49
+ // Sub2API and several private OpenAI-compatible gateways expose a lightweight
50
+ // OpenAI-style endpoint at /v1/usage. This is intentionally probed first for
51
+ // generic non-OpenAI base URLs because it does not require a management token.
52
+ async function fetchV1Usage(context) {
53
+ const json = await requestJson(await subscriptionUrl(context.baseUrl), context.key, {
54
+ name: "v1 usage",
55
+ });
56
+ if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
57
+ return usageResult(context, "v1-usage", await formatQuota(context.label, json), json);
58
+ }
59
+
60
+ // NewAPI / OneAPI family panels: use the current API key as Bearer auth and
61
+ // query token usage from the service root rather than the /v1 OpenAI-compatible
62
+ // path.
63
+ async function fetchNewApiTokenUsage(context) {
64
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), context.key, {
65
+ name: "NewAPI token usage",
66
+ });
67
+ const root = usageRoot(json);
68
+ const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
69
+ const used = pickNumber(root, ["used_quota", "usedQuota", "used"]);
70
+ let remaining = pickNumber(root, ["remain_quota", "remainQuota", "remaining", "balance"]);
71
+ if (remaining === undefined && quota !== undefined && used !== undefined) {
72
+ remaining = Math.max(0, quota - used);
73
+ }
74
+ const scale = await newApiQuotaScale();
75
+ const quotaForWarning = scale ? quota / scale : quota;
76
+ const usedForWarning = scale ? used / scale : used;
77
+ const remainingForWarning = scale ? remaining / scale : remaining;
78
+ const normalized = {
79
+ mode: "quota_limited",
80
+ quota: {
81
+ limit: quotaForWarning,
82
+ used: usedForWarning,
83
+ remaining: remainingForWarning,
84
+ },
85
+ unit: scale ? "USD" : "quota",
86
+ source: "newapi-token",
87
+ raw: json,
88
+ };
89
+ return usageResult(context, "newapi-token", await formatNewApiTokenLine(context.label, json), normalized);
90
+ }
91
+
92
+ // NewAPI / OneAPI / OneHub / DoneHub / Veloera panel session endpoint, based on
93
+ // Metapi's platform handling. This works when PROVIDER_USAGE_API_KEY is a panel
94
+ // access/session token, or when the site accepts the API key for /api/user/self.
95
+ async function fetchPanelUserSelfUsage(context) {
96
+ const preset = await usagePreset();
97
+ const kind = preset === "auto" ? "new-api" : preset;
98
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), context.key, {
99
+ name: "panel /api/user/self",
100
+ headers: await panelUserHeaders(),
101
+ });
102
+ const root = usageRoot(json);
103
+ if (pickNumber(root, ["quota"]) === undefined && pickNumber(root, ["used_quota", "usedQuota"]) === undefined) {
104
+ await debugLog({
105
+ source: "panel /api/user/self",
106
+ payloadKeys: Object.keys(root || {}).slice(0, 20),
107
+ success: root?.success,
108
+ message: root?.message || root?.error?.message || "",
109
+ });
110
+ }
111
+ const scale = panelQuotaScale(kind);
112
+ const quota = pickNumber(root, ["quota"]);
113
+ const used = pickNumber(root, ["used_quota", "usedQuota"]);
114
+ const remaining = panelQuotaLooksRemaining(kind)
115
+ ? quota
116
+ : (quota === undefined || used === undefined ? quota : Math.max(0, quota - used));
117
+ const total = panelQuotaLooksRemaining(kind)
118
+ ? (quota === undefined || used === undefined ? quota : quota + used)
119
+ : quota;
120
+ const normalized = {
121
+ mode: "quota_limited",
122
+ quota: {
123
+ limit: total === undefined ? undefined : total / scale,
124
+ used: used === undefined ? undefined : used / scale,
125
+ remaining: remaining === undefined ? undefined : remaining / scale,
126
+ },
127
+ unit: "USD",
128
+ source: "panel-user-self",
129
+ raw: json,
130
+ };
131
+ return usageResult(
132
+ context,
133
+ "panel-user-self",
134
+ await formatPanelUserSelfLine(context.label, json, kind),
135
+ normalized
136
+ );
137
+ }
138
+
139
+ // Sub2API exposes user balance as USD at /api/v1/auth/me. Newer deployments may
140
+ // also expose richer subscription summaries through /v1/usage, so this route is
141
+ // a fallback for deployments where /v1/usage is unavailable.
142
+ async function fetchSub2ApiAuthMeUsage(context) {
143
+ const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), context.key, {
144
+ name: "Sub2API auth/me",
145
+ });
146
+ const root = usageRoot(json);
147
+ const balance = pickNumber(root, ["balance"]);
148
+ if (balance === undefined) throw new Error("Sub2API auth/me payload has no balance field");
149
+ const normalized = {
150
+ mode: "unrestricted",
151
+ planName: root?.username || root?.email || context.label || "Sub2API",
152
+ balance,
153
+ unit: "USD",
154
+ source: "sub2api-auth-me",
155
+ raw: json,
156
+ };
157
+ return usageResult(
158
+ context,
159
+ "sub2api-auth-me",
160
+ `API | balance ${formatMoney(balance)}`,
161
+ normalized
162
+ );
163
+ }
164
+
165
+ // OpenRouter exposes normal API-key usage at /api/v1/key. Some accounts also
166
+ // expose credits at /api/v1/credits; keep this route isolated because
167
+ // OpenRouter's base URL already includes /api/v1, unlike NewAPI/OneAPI.
168
+ async function fetchOpenRouterUsage(context) {
169
+ const base = cleanBaseUrl(context.baseUrl).includes("/api/v1")
170
+ ? cleanBaseUrl(context.baseUrl)
171
+ : joinUrl(serviceRoot(context.baseUrl), "/api/v1");
172
+ const endpoints = [
173
+ { source: "openrouter-key", url: joinUrl(base, "/key") },
174
+ { source: "openrouter-credits", url: joinUrl(base, "/credits") },
175
+ ];
176
+ let lastError;
177
+ for (const endpoint of endpoints) {
178
+ try {
179
+ const json = await requestJson(endpoint.url, context.key, { name: endpoint.source });
180
+ return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
181
+ } catch (error) {
182
+ lastError = error;
183
+ await debugLog({ source: endpoint.source, error: error.message });
184
+ }
185
+ }
186
+ throw lastError || new Error("OpenRouter usage unavailable");
187
+ }
188
+
189
+ const USAGE_ROUTES = {
190
+ "v1-usage": {
191
+ id: "v1-usage",
192
+ path: "/v1/usage",
193
+ run: fetchV1Usage,
194
+ },
195
+ "sub2api-auth-me": {
196
+ id: "sub2api-auth-me",
197
+ path: "/api/v1/auth/me",
198
+ run: fetchSub2ApiAuthMeUsage,
199
+ },
200
+ "newapi-token": {
201
+ id: "newapi-token",
202
+ path: "/api/usage/token/",
203
+ run: fetchNewApiTokenUsage,
204
+ },
205
+ "panel-user-self": {
206
+ id: "panel-user-self",
207
+ path: "/api/user/self",
208
+ run: fetchPanelUserSelfUsage,
209
+ },
210
+ "openrouter": {
211
+ id: "openrouter",
212
+ path: "/api/v1/key",
213
+ run: fetchOpenRouterUsage,
214
+ },
215
+ };
216
+
217
+ // Presets are probe-order aliases over the routes above, not separate
218
+ // protocols (e.g. anyrouter/agentrouter just try the NewAPI panel endpoints
219
+ // and /v1/usage in a different order).
220
+ // They do not provide panel session-cookie authentication; only endpoints that
221
+ // accept the configured Bearer key can succeed.
222
+ async function usageRouteIds(context) {
223
+ const preset = await usagePreset();
224
+ const routes = {
225
+ "sub2api": ["v1-usage", "sub2api-auth-me"],
226
+ "openai-compatible": ["v1-usage"],
227
+ "new-api": ["newapi-token", "panel-user-self"],
228
+ "one-api": ["newapi-token", "panel-user-self"],
229
+ "onehub": ["newapi-token", "panel-user-self"],
230
+ "one-hub": ["newapi-token", "panel-user-self"],
231
+ "donehub": ["newapi-token", "panel-user-self"],
232
+ "done-hub": ["newapi-token", "panel-user-self"],
233
+ "veloera": ["panel-user-self", "newapi-token"],
234
+ "anyrouter": ["newapi-token", "panel-user-self", "v1-usage"],
235
+ "agentrouter": ["newapi-token", "panel-user-self", "v1-usage"],
236
+ "openrouter": ["openrouter"],
237
+ };
238
+ if (routes[preset]) return routes[preset];
239
+
240
+ if (preset !== "auto") return [];
241
+ if (hostIncludes(context.baseUrl, "openrouter.ai")) return ["openrouter"];
242
+ return ["v1-usage", "sub2api-auth-me", "newapi-token", "panel-user-self"];
243
+ }
244
+
245
+ async function cachedUsageRoute(context) {
246
+ const cache = await readRouteCache();
247
+ const key = usageRouteCacheKey(context.baseUrl);
248
+ const route = cache.routes[key];
249
+ return route?.route && USAGE_ROUTES[route.route] ? route : null;
250
+ }
251
+
252
+ export async function orderedUsageRoutes(context) {
253
+ const routeIds = await usageRouteIds(context);
254
+ const cached = await cachedUsageRoute(context);
255
+ if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) => USAGE_ROUTES[id]).filter(Boolean);
256
+ await debugLog({
257
+ source: "route-cache",
258
+ key: usageRouteCacheKey(context.baseUrl),
259
+ route: cached.route,
260
+ path: cached.path || USAGE_ROUTES[cached.route]?.path || "",
261
+ });
262
+ return [cached.route, ...routeIds.filter((id) => id !== cached.route)]
263
+ .map((id) => USAGE_ROUTES[id])
264
+ .filter(Boolean);
265
+ }
@@ -0,0 +1,48 @@
1
+ // Pure URL helpers for gateway base URLs and route cache keys.
2
+
3
+ export function isOfficialBaseUrl(baseUrl) {
4
+ if (!baseUrl) return true;
5
+ const clean = baseUrl.replace(/\/+$/, "");
6
+ return [
7
+ "https://api.openai.com",
8
+ "https://api.openai.com/v1",
9
+ "https://api.anthropic.com",
10
+ "https://api.anthropic.com/v1",
11
+ ].includes(clean);
12
+ }
13
+
14
+ export function cleanBaseUrl(baseUrl) {
15
+ return String(baseUrl || "").replace(/\/+$/, "");
16
+ }
17
+
18
+ export function serviceRoot(baseUrl) {
19
+ const clean = cleanBaseUrl(baseUrl);
20
+ return clean.endsWith("/v1") ? clean.slice(0, -3) : clean;
21
+ }
22
+
23
+ export function usageRouteCacheKey(baseUrl) {
24
+ try {
25
+ const url = new URL(cleanBaseUrl(baseUrl));
26
+ url.hash = "";
27
+ url.search = "";
28
+ url.pathname = url.pathname
29
+ .replace(/\/+$/, "")
30
+ .replace(/\/api\/v1$/i, "")
31
+ .replace(/\/v1$/i, "");
32
+ return url.toString().replace(/\/$/, "");
33
+ } catch {
34
+ return serviceRoot(baseUrl);
35
+ }
36
+ }
37
+
38
+ export function joinUrl(baseUrl, path) {
39
+ return `${cleanBaseUrl(baseUrl)}${path.startsWith("/") ? path : `/${path}`}`;
40
+ }
41
+
42
+ export function hostIncludes(baseUrl, value) {
43
+ try {
44
+ return new URL(baseUrl).hostname.toLowerCase().includes(value);
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairyou/agent-tools",
3
- "version": "0.4.0",
3
+ "version": "0.5.2",
4
4
  "description": "Reusable Agent Skills and installable integrations (statusline, provider usage, vision) for Codex, Claude Code, and opencode.",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -102,40 +102,6 @@ function nodeCmd(absScript) {
102
102
  return `node "${fwd(absScript)}"`;
103
103
  }
104
104
 
105
- function stripJsonComments(input) {
106
- let out = "";
107
- let inString = false;
108
- let escaped = false;
109
- for (let i = 0; i < input.length; i++) {
110
- const ch = input[i];
111
- const next = input[i + 1];
112
- if (inString) {
113
- out += ch;
114
- escaped = ch === "\\" ? !escaped : false;
115
- if (ch === "\"" && !escaped) inString = false;
116
- continue;
117
- }
118
- if (ch === "\"") {
119
- inString = true;
120
- out += ch;
121
- continue;
122
- }
123
- if (ch === "/" && next === "/") {
124
- while (i < input.length && input[i] !== "\n") i++;
125
- out += "\n";
126
- continue;
127
- }
128
- if (ch === "/" && next === "*") {
129
- i += 2;
130
- while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
131
- i++;
132
- continue;
133
- }
134
- out += ch;
135
- }
136
- return out;
137
- }
138
-
139
105
  function readJsonc(file) {
140
106
  if (!fs.existsSync(file)) return {};
141
107
  const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
@@ -146,31 +112,6 @@ function readJsonc(file) {
146
112
  return parsed ?? {};
147
113
  }
148
114
 
149
- function headerComments(text) {
150
- const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
151
- const header = [];
152
- for (const line of lines) {
153
- if (/^\s*(?:\/\/.*)?$/.test(line)) {
154
- header.push(line);
155
- continue;
156
- }
157
- break;
158
- }
159
- return header.length ? header.join("\n").replace(/\s+$/, "") + "\n" : "";
160
- }
161
-
162
- function mergeDefaults(target, defaults) {
163
- if (Array.isArray(defaults)) return target === undefined ? defaults : target;
164
- if (!defaults || typeof defaults !== "object") {
165
- return target === undefined ? defaults : target;
166
- }
167
- const out = target && typeof target === "object" && !Array.isArray(target) ? { ...target } : {};
168
- for (const [key, value] of Object.entries(defaults)) {
169
- out[key] = mergeDefaults(out[key], value);
170
- }
171
- return out;
172
- }
173
-
174
115
  function writeText(file, text, dryRun) {
175
116
  if (dryRun) {
176
117
  console.log(` [dry-run] would write ${file}:`);
@@ -215,23 +156,46 @@ function updateOpenCodeTuiConfig(file, { remove, dryRun }) {
215
156
  writeText(file, updated, dryRun);
216
157
  }
217
158
 
159
+ // Keys present in defaults but absent in current, as jsonc-parser edit paths.
160
+ function missingDefaultPaths(current, defaults, basePath = []) {
161
+ const out = [];
162
+ for (const [key, value] of Object.entries(defaults)) {
163
+ const existing = current?.[key];
164
+ if (existing === undefined) {
165
+ out.push({ path: [...basePath, key], value });
166
+ } else if (
167
+ value && typeof value === "object" && !Array.isArray(value) &&
168
+ existing && typeof existing === "object" && !Array.isArray(existing)
169
+ ) {
170
+ out.push(...missingDefaultPaths(existing, value, [...basePath, key]));
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ // Updates only add missing default keys via surgical jsonc-parser edits, so
177
+ // the user's comments, formatting, and key order survive installer updates.
218
178
  function mergeJsoncFile(src, dest, dryRun) {
219
- const defaults = readJsonc(src);
220
- const defaultHeader = headerComments(fs.readFileSync(src, "utf8"));
221
- if (!fs.existsSync(dest)) {
179
+ if (!fs.existsSync(dest) || !fs.readFileSync(dest, "utf8").trim()) {
222
180
  writeText(dest, fs.readFileSync(src, "utf8"), dryRun);
223
181
  return;
224
182
  }
183
+ const defaults = readJsonc(src);
225
184
  const currentText = fs.readFileSync(dest, "utf8");
226
- const current = readJsonc(dest);
227
- const merged = mergeDefaults(current, defaults);
228
- const currentHeader = headerComments(currentText) || defaultHeader;
229
- const mergedText = currentHeader + JSON.stringify(merged, null, 2) + "\n";
230
- if (stripJsonComments(currentText).trim() === JSON.stringify(merged, null, 2)) {
185
+ const additions = missingDefaultPaths(readJsonc(dest), defaults);
186
+ if (additions.length === 0) {
231
187
  console.log(` kept existing ${dest}`);
232
188
  return;
233
189
  }
234
- writeText(dest, mergedText, dryRun);
190
+ const eol = currentText.includes("\r\n") ? "\r\n" : "\n";
191
+ let updated = currentText;
192
+ for (const { path: keyPath, value } of additions) {
193
+ const edits = modify(updated, keyPath, value, {
194
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol },
195
+ });
196
+ updated = applyEdits(updated, edits);
197
+ }
198
+ writeText(dest, updated, dryRun);
235
199
  }
236
200
 
237
201
  function copyRuntimeFile(src, dest, dryRun, options = {}) {