← back to Kickbacks Ai Upstream
src/adapters/claude-cli/settingsEdit.ts
213 lines
/** Minimal-edit, JSONC-tolerant editing of ~/.claude/settings.json.
* We mutate RAW TEXT (never re-serialize) so the user's comments,
* whitespace and key order survive. A tiny state machine walks the raw
* bytes tracking string/line-comment/block-comment context. */
type Ctx = "code" | "str" | "line" | "block";
/** Strip JSONC comments + a single trailing comma before } or ] so we can
* validate with JSON.parse. Used ONLY for parse/idempotency checks, never
* for the emitted text. */
function stripJsonc(src: string): string {
let out = "", ctx: Ctx = "code", i = 0;
while (i < src.length) {
const c = src[i], n = src[i + 1];
if (ctx === "code") {
if (c === '"') { ctx = "str"; out += c; i++; continue; }
if (c === "/" && n === "/") { ctx = "line"; i += 2; continue; }
if (c === "/" && n === "*") { ctx = "block"; i += 2; continue; }
out += c; i++; continue;
}
if (ctx === "str") {
out += c;
if (c === "\\") { out += src[i + 1] ?? ""; i += 2; continue; }
if (c === '"') ctx = "code";
i++; continue;
}
if (ctx === "line") { if (c === "\n") { ctx = "code"; out += c; } i++; continue; }
// block
if (c === "*" && n === "/") { ctx = "code"; i += 2; continue; }
i++;
}
return out.replace(/,(\s*[}\]])/g, "$1");
}
export function parseable(src: string): boolean {
try { JSON.parse(stripJsonc(src)); return true; } catch { return false; }
}
/** Find the [start,end) raw-text span of the VALUE of a top-level `key`, or
* null if the key is absent. Comment/string aware; only depth-1 keys (direct
* children of the root object) match. Throws if the text is not parseable
* as JSONC. */
function findTopLevelValueSpan(src: string, key: string): [number, number] | null {
if (!parseable(src)) throw new Error("settings.json not parseable");
let ctx: Ctx = "code", depth = 0, i = 0;
let pendingKey: string | null = null, keyStart = -1;
const skipWs = (j: number): number => {
let c2: Ctx = "code";
while (j < src.length) {
const c = src[j], n = src[j + 1];
if (c2 === "code") {
if (c === "/" && n === "/") { c2 = "line"; j += 2; continue; }
if (c === "/" && n === "*") { c2 = "block"; j += 2; continue; }
if (/\s/.test(c) || c === ":") { j++; continue; }
return j;
}
if (c2 === "line") { if (c === "\n") c2 = "code"; j++; continue; }
if (c === "*" && n === "/") { c2 = "code"; j += 2; continue; }
j++;
}
return j;
};
const valueEnd = (j: number): number => {
// j points at first char of the value; consume one JSON value.
let c2: Ctx = "code", d = 0;
for (; j < src.length; j++) {
const c = src[j], n = src[j + 1];
if (c2 === "str") {
if (c === "\\") { j++; continue; }
if (c === '"') c2 = "code";
continue;
}
if (c2 === "line") { if (c === "\n") c2 = "code"; continue; }
if (c2 === "block") { if (c === "*" && n === "/") { c2 = "code"; j++; } continue; }
if (c === '"') { c2 = "str"; continue; }
if (c === "/" && n === "/") { c2 = "line"; j++; continue; }
if (c === "/" && n === "*") { c2 = "block"; j++; continue; }
if (c === "{" || c === "[") d++;
else if (c === "}" || c === "]") { if (d === 0) return j; d--; }
else if ((c === "," ) && d === 0) return j;
}
return j;
};
while (i < src.length) {
const c = src[i], n = src[i + 1];
if (ctx === "str") {
if (c === "\\") { i += 2; continue; }
if (c === '"') {
ctx = "code";
if (depth === 1) pendingKey = src.slice(keyStart + 1, i);
}
i++; continue;
}
if (ctx === "line") { if (c === "\n") ctx = "code"; i++; continue; }
if (ctx === "block") { if (c === "*" && n === "/") { ctx = "code"; i += 2; continue; } i++; continue; }
if (c === "/" && n === "/") { ctx = "line"; i += 2; continue; }
if (c === "/" && n === "*") { ctx = "block"; i += 2; continue; }
if (c === '"') { ctx = "str"; keyStart = i; i++; continue; }
if (c === "{" || c === "[") { depth++; i++; continue; }
if (c === "}" || c === "]") { depth--; i++; continue; }
if (c === ":" && depth === 1 && pendingKey === key) {
const vs = skipWs(i + 1);
return [vs, valueEnd(vs)];
}
if (c === "," ) pendingKey = null;
i++;
}
return null;
}
/** Parse and return the VALUE of a top-level `key`, or undefined when the
* key is absent or the text is not parseable. Read-only companion to
* `upsertTopLevel` — the CLI adapter uses it to capture a pre-existing
* user statusLine before taking the slot (chain-capture). */
export function readTopLevel(src: string, key: string): unknown {
try {
const span = findTopLevelValueSpan(src, key);
if (!span) return undefined;
return JSON.parse(stripJsonc(src.slice(span[0], span[1])));
} catch { return undefined; }
}
/** Set the top-level `key` to `valueJson` (a JSON value string), editing only
* that span. Inserts the key right after the root `{` when absent.
* Idempotent. Throws if `src` is not parseable JSONC. */
function upsertTopLevel(src: string, key: string, valueJson: string): string {
const span = findTopLevelValueSpan(src, key);
if (span) {
const next = src.slice(0, span[0]) + valueJson + src.slice(span[1]);
return next;
}
const brace = src.indexOf("{");
if (brace < 0) throw new Error("settings.json not parseable");
const after = src.slice(brace + 1);
const hasKeys = parseable(src) && /\S/.test(stripJsonc(after).replace(/[}\s]/g, ""));
const insert = `\n ${JSON.stringify(key)}: ${valueJson}${hasKeys ? "," : ""}`;
return src.slice(0, brace + 1) + insert + after;
}
/** Set top-level "statusLine" to `valueJson`. See `upsertTopLevel`. */
export function upsertStatusLine(src: string, valueJson: string): string {
return upsertTopLevel(src, "statusLine", valueJson);
}
/** Set top-level "spinnerVerbs" to `valueJson`. CC 2.1.143+ reads this at boot
* via the iTH() selector to override the default verb dictionary in the
* thinking-shimmer. Schema (from claude.exe at offset 217693700):
* `{ mode: "append" | "replace", verbs: string[] }`. See `upsertTopLevel`. */
export function upsertSpinnerVerbs(src: string, valueJson: string): string {
return upsertTopLevel(src, "spinnerVerbs", valueJson);
}
/** Remove a top-level key (and its preceding comma, if any) from a JSONC
* settings file. Idempotent — returns `src` unchanged when the key is
* absent. Comment/string aware; preserves the user's other keys, their
* ordering, and their comments. Used to evict the spinnerVerbs entry
* the older CLI adapter wrote into settings.json — once it's gone CC
* falls back to its built-in verb dictionary and our webview block
* becomes the SINGLE ad-rendering surface (no plain-text fallback that
* silently masks a block.desync). */
export function removeTopLevel(src: string, key: string): string {
let span: [number, number] | null;
try { span = findTopLevelValueSpan(src, key); }
catch { return src; } // not parseable → leave alone
if (!span) return src;
// Walk backward from the value span to consume the key, its colon,
// and any leading whitespace + a single preceding comma; walk forward
// to swallow a trailing comma (so removing a middle key doesn't leave
// `{ "a": 1,, "b": 2 }`).
let s = span[0];
// Step back past whitespace + colon + whitespace + the key string.
while (s > 0 && /\s/.test(src[s - 1])) s--;
if (s > 0 && src[s - 1] === ":") s--;
while (s > 0 && /\s/.test(src[s - 1])) s--;
if (s > 0 && src[s - 1] === '"') {
// Find the matching opening quote, respecting backslash escapes.
let q = s - 2;
while (q > 0) {
if (src[q] === '"' && src[q - 1] !== "\\") break;
q--;
}
s = q;
}
// Trailing comma + whitespace (or leading comma if no trailing one).
let e = span[1];
let trailingCommaConsumed = false;
let j = e;
while (j < src.length && /\s/.test(src[j])) j++;
if (src[j] === ",") { e = j + 1; trailingCommaConsumed = true; }
if (trailingCommaConsumed) {
// Also consume the whitespace run immediately before the key so removing
// an entry upsertTopLevel inserted (`\n "key": value,` right after the
// root `{`) round-trips byte-exact — the CLI adapter's key-scoped
// restore() relies on this to leave a never-edited settings.json exactly
// as it was before applyPatch. Whitespace only: a comment between the
// previous token and the key is never consumed.
while (s > 0 && /\s/.test(src[s - 1])) s--;
}
if (!trailingCommaConsumed) {
// No trailing comma → consume any leading comma + ws so the result
// doesn't end with a dangling `, }`.
let k = s;
while (k > 0 && /\s/.test(src[k - 1])) k--;
if (k > 0 && src[k - 1] === ",") s = k - 1;
}
return src.slice(0, s) + src.slice(e);
}
/** Remove the top-level "spinnerVerbs" entry. Convenience wrapper. */
export function removeSpinnerVerbs(src: string): string {
return removeTopLevel(src, "spinnerVerbs");
}