diff --git a/README.md b/README.md index 7912418..d3c1047 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Auto-sync OpenCode configs from CLIProxyAPI Dashboard. ## What it does -This plugin automatically syncs your OpenCode configuration (`opencode.json` and `oh-my-opencode.json`) from the CLIProxyAPI Dashboard. It checks for config updates on OpenCode startup and applies them automatically. +This plugin automatically syncs your OpenCode configuration (`opencode.json`, the Oh-My-OpenAgent config in `~/.omo/omo.jsonc` and `oh-my-opencode-slim.jsonc`) from the CLIProxyAPI Dashboard. It checks for config updates on OpenCode startup and applies them automatically. ## Features @@ -81,7 +81,7 @@ Create the plugin config file: 1. **On OpenCode Startup**: Plugin loads and checks your config 2. **Version Check**: Fetches current config version from dashboard 3. **Compare**: If version differs from `lastKnownVersion`, fetches full bundle -4. **Write Configs**: Atomically writes `opencode.json` and `oh-my-opencode.json` +4. **Write Configs**: Atomically writes `opencode.json`, the `"[opencode]"` block of `~/.omo/omo.jsonc` and `oh-my-opencode-slim.jsonc` 5. **Notify**: Shows toast if `opencode.json` changed (requiring restart) 6. **Update Version**: Saves new version to plugin config @@ -99,22 +99,30 @@ The plugin shows a toast notification **only when** `opencode.json` changes: > ⚠️ **Config Sync** > opencode.json updated. Restart OpenCode to apply provider changes. -If only `oh-my-opencode.json` changes, no notification is shown (those changes apply immediately on next session). +If only the Oh-My-OpenAgent or slim config changes, no notification is shown (those changes apply immediately on next session). ## File Locations **Standard (no profile):** - **Plugin Config**: `~/.config/opencode-cliproxyapi-sync/config.json` - **OpenCode Config**: `~/.config/opencode/opencode.json` -- **Oh My OpenCode Config**: `~/.config/opencode/oh-my-opencode.json` +- **Oh-My-OpenAgent Config**: `"[opencode]"` block of `~/.omo/omo.jsonc` +- **oh-my-opencode-slim Config**: `~/.config/opencode/oh-my-opencode-slim.jsonc` **With OCX profile:** - **Plugin Config**: `~/.config/opencode/profiles//opencode-cliproxyapi-sync/config.json` - **OpenCode Config**: `~/.config/opencode/profiles//opencode.json` -- **Oh My OpenCode Config**: `~/.config/opencode/profiles//oh-my-opencode.json` +- **Oh-My-OpenAgent Config**: `"[opencode]"` block of `~/.omo/omo.jsonc` (shared by all profiles) +- **oh-my-opencode-slim Config**: `~/.config/opencode/profiles//oh-my-opencode-slim.jsonc` (Respects `XDG_CONFIG_HOME` and `OPENCODE_CONFIG_DIR` environment variables) +### Oh-My-OpenAgent (`~/.omo/omo.jsonc`) + +Oh-My-OpenAgent 4.19.3+ only reads `~/.omo/omo.jsonc` (falling back to `omo.json`). The plugin replaces only the `"[opencode]"` block in that file; comments, formatting and other blocks such as `"[native]"` or `"[codex]"` are kept. If `~/.omo/` or the file does not exist, it is created. + +Older files (`oh-my-opencode.json`, `oh-my-opencode-slim.json`) are no longer written and are never deleted or modified. + ## OCX Profile Support When using [OCX](https://github.com/kdcokenny/ocx) profiles, the plugin uses **per-profile configuration**. This means: @@ -176,7 +184,7 @@ cat ~/.config/opencode/profiles//opencode-cliproxyapi-sync/config.j ### Notification not showing - Notification only appears when `opencode.json` changes -- If only `oh-my-opencode.json` changed, no notification is shown (expected) +- If only the Oh-My-OpenAgent or slim config changed, no notification is shown (expected) ## Development diff --git a/bun.lock b/bun.lock index 8c1c563..6585ba9 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "opencode-cliproxyapi-sync", + "dependencies": { + "jsonc-parser": "^3.3.1", + }, "devDependencies": { "@opencode-ai/plugin": "latest", "@types/node": "^22.0.0", @@ -24,6 +27,8 @@ "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/dist/index.js b/dist/index.js index 8c89a0e..e5427ea 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,9 +1,6 @@ import { createRequire } from "node:module"; var __require = /* @__PURE__ */ createRequire(import.meta.url); -// src/index.ts -import { join as join3 } from "path"; - // src/config.ts import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs"; import { homedir } from "os"; @@ -127,10 +124,1300 @@ async function fetchBundle(dashboardUrl, token) { } // src/writer.ts -import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, renameSync } from "fs"; -import { createHash } from "crypto"; +import { + existsSync as existsSync2, + mkdirSync as mkdirSync2, + readFileSync as readFileSync2, + renameSync, + writeFileSync as writeFileSync2 +} from "fs"; import { homedir as homedir2 } from "os"; import { join as join2 } from "path"; + +// node_modules/jsonc-parser/lib/esm/impl/scanner.js +function createScanner(text, ignoreTrivia = false) { + const len = text.length; + let pos = 0, value = "", tokenOffset = 0, token = 16, lineNumber = 0, lineStartOffset = 0, tokenLineStartOffset = 0, prevTokenLineStartOffset = 0, scanError = 0; + function scanHexDigits(count, exact) { + let digits = 0; + let value2 = 0; + while (digits < count || !exact) { + let ch = text.charCodeAt(pos); + if (ch >= 48 && ch <= 57) { + value2 = value2 * 16 + ch - 48; + } else if (ch >= 65 && ch <= 70) { + value2 = value2 * 16 + ch - 65 + 10; + } else if (ch >= 97 && ch <= 102) { + value2 = value2 * 16 + ch - 97 + 10; + } else { + break; + } + pos++; + digits++; + } + if (digits < count) { + value2 = -1; + } + return value2; + } + function setPosition(newPosition) { + pos = newPosition; + value = ""; + tokenOffset = 0; + token = 16; + scanError = 0; + } + function scanNumber() { + let start = pos; + if (text.charCodeAt(pos) === 48) { + pos++; + } else { + pos++; + while (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + } + } + if (pos < text.length && text.charCodeAt(pos) === 46) { + pos++; + if (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + while (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + } + } else { + scanError = 3; + return text.substring(start, pos); + } + } + let end = pos; + if (pos < text.length && (text.charCodeAt(pos) === 69 || text.charCodeAt(pos) === 101)) { + pos++; + if (pos < text.length && text.charCodeAt(pos) === 43 || text.charCodeAt(pos) === 45) { + pos++; + } + if (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + while (pos < text.length && isDigit(text.charCodeAt(pos))) { + pos++; + } + end = pos; + } else { + scanError = 3; + } + } + return text.substring(start, end); + } + function scanString() { + let result = "", start = pos; + while (true) { + if (pos >= len) { + result += text.substring(start, pos); + scanError = 2; + break; + } + const ch = text.charCodeAt(pos); + if (ch === 34) { + result += text.substring(start, pos); + pos++; + break; + } + if (ch === 92) { + result += text.substring(start, pos); + pos++; + if (pos >= len) { + scanError = 2; + break; + } + const ch2 = text.charCodeAt(pos++); + switch (ch2) { + case 34: + result += '"'; + break; + case 92: + result += "\\"; + break; + case 47: + result += "/"; + break; + case 98: + result += "\b"; + break; + case 102: + result += "\f"; + break; + case 110: + result += ` +`; + break; + case 114: + result += "\r"; + break; + case 116: + result += "\t"; + break; + case 117: + const ch3 = scanHexDigits(4, true); + if (ch3 >= 0) { + result += String.fromCharCode(ch3); + } else { + scanError = 4; + } + break; + default: + scanError = 5; + } + start = pos; + continue; + } + if (ch >= 0 && ch <= 31) { + if (isLineBreak(ch)) { + result += text.substring(start, pos); + scanError = 2; + break; + } else { + scanError = 6; + } + } + pos++; + } + return result; + } + function scanNext() { + value = ""; + scanError = 0; + tokenOffset = pos; + lineStartOffset = lineNumber; + prevTokenLineStartOffset = tokenLineStartOffset; + if (pos >= len) { + tokenOffset = len; + return token = 17; + } + let code = text.charCodeAt(pos); + if (isWhiteSpace(code)) { + do { + pos++; + value += String.fromCharCode(code); + code = text.charCodeAt(pos); + } while (isWhiteSpace(code)); + return token = 15; + } + if (isLineBreak(code)) { + pos++; + value += String.fromCharCode(code); + if (code === 13 && text.charCodeAt(pos) === 10) { + pos++; + value += ` +`; + } + lineNumber++; + tokenLineStartOffset = pos; + return token = 14; + } + switch (code) { + case 123: + pos++; + return token = 1; + case 125: + pos++; + return token = 2; + case 91: + pos++; + return token = 3; + case 93: + pos++; + return token = 4; + case 58: + pos++; + return token = 6; + case 44: + pos++; + return token = 5; + case 34: + pos++; + value = scanString(); + return token = 10; + case 47: + const start = pos - 1; + if (text.charCodeAt(pos + 1) === 47) { + pos += 2; + while (pos < len) { + if (isLineBreak(text.charCodeAt(pos))) { + break; + } + pos++; + } + value = text.substring(start, pos); + return token = 12; + } + if (text.charCodeAt(pos + 1) === 42) { + pos += 2; + const safeLength = len - 1; + let commentClosed = false; + while (pos < safeLength) { + const ch = text.charCodeAt(pos); + if (ch === 42 && text.charCodeAt(pos + 1) === 47) { + pos += 2; + commentClosed = true; + break; + } + pos++; + if (isLineBreak(ch)) { + if (ch === 13 && text.charCodeAt(pos) === 10) { + pos++; + } + lineNumber++; + tokenLineStartOffset = pos; + } + } + if (!commentClosed) { + pos++; + scanError = 1; + } + value = text.substring(start, pos); + return token = 13; + } + value += String.fromCharCode(code); + pos++; + return token = 16; + case 45: + value += String.fromCharCode(code); + pos++; + if (pos === len || !isDigit(text.charCodeAt(pos))) { + return token = 16; + } + case 48: + case 49: + case 50: + case 51: + case 52: + case 53: + case 54: + case 55: + case 56: + case 57: + value += scanNumber(); + return token = 11; + default: + while (pos < len && isUnknownContentCharacter(code)) { + pos++; + code = text.charCodeAt(pos); + } + if (tokenOffset !== pos) { + value = text.substring(tokenOffset, pos); + switch (value) { + case "true": + return token = 8; + case "false": + return token = 9; + case "null": + return token = 7; + } + return token = 16; + } + value += String.fromCharCode(code); + pos++; + return token = 16; + } + } + function isUnknownContentCharacter(code) { + if (isWhiteSpace(code) || isLineBreak(code)) { + return false; + } + switch (code) { + case 125: + case 93: + case 123: + case 91: + case 34: + case 58: + case 44: + case 47: + return false; + } + return true; + } + function scanNextNonTrivia() { + let result; + do { + result = scanNext(); + } while (result >= 12 && result <= 15); + return result; + } + return { + setPosition, + getPosition: () => pos, + scan: ignoreTrivia ? scanNextNonTrivia : scanNext, + getToken: () => token, + getTokenValue: () => value, + getTokenOffset: () => tokenOffset, + getTokenLength: () => pos - tokenOffset, + getTokenStartLine: () => lineStartOffset, + getTokenStartCharacter: () => tokenOffset - prevTokenLineStartOffset, + getTokenError: () => scanError + }; +} +function isWhiteSpace(ch) { + return ch === 32 || ch === 9; +} +function isLineBreak(ch) { + return ch === 10 || ch === 13; +} +function isDigit(ch) { + return ch >= 48 && ch <= 57; +} +var CharacterCodes; +(function(CharacterCodes2) { + CharacterCodes2[CharacterCodes2["lineFeed"] = 10] = "lineFeed"; + CharacterCodes2[CharacterCodes2["carriageReturn"] = 13] = "carriageReturn"; + CharacterCodes2[CharacterCodes2["space"] = 32] = "space"; + CharacterCodes2[CharacterCodes2["_0"] = 48] = "_0"; + CharacterCodes2[CharacterCodes2["_1"] = 49] = "_1"; + CharacterCodes2[CharacterCodes2["_2"] = 50] = "_2"; + CharacterCodes2[CharacterCodes2["_3"] = 51] = "_3"; + CharacterCodes2[CharacterCodes2["_4"] = 52] = "_4"; + CharacterCodes2[CharacterCodes2["_5"] = 53] = "_5"; + CharacterCodes2[CharacterCodes2["_6"] = 54] = "_6"; + CharacterCodes2[CharacterCodes2["_7"] = 55] = "_7"; + CharacterCodes2[CharacterCodes2["_8"] = 56] = "_8"; + CharacterCodes2[CharacterCodes2["_9"] = 57] = "_9"; + CharacterCodes2[CharacterCodes2["a"] = 97] = "a"; + CharacterCodes2[CharacterCodes2["b"] = 98] = "b"; + CharacterCodes2[CharacterCodes2["c"] = 99] = "c"; + CharacterCodes2[CharacterCodes2["d"] = 100] = "d"; + CharacterCodes2[CharacterCodes2["e"] = 101] = "e"; + CharacterCodes2[CharacterCodes2["f"] = 102] = "f"; + CharacterCodes2[CharacterCodes2["g"] = 103] = "g"; + CharacterCodes2[CharacterCodes2["h"] = 104] = "h"; + CharacterCodes2[CharacterCodes2["i"] = 105] = "i"; + CharacterCodes2[CharacterCodes2["j"] = 106] = "j"; + CharacterCodes2[CharacterCodes2["k"] = 107] = "k"; + CharacterCodes2[CharacterCodes2["l"] = 108] = "l"; + CharacterCodes2[CharacterCodes2["m"] = 109] = "m"; + CharacterCodes2[CharacterCodes2["n"] = 110] = "n"; + CharacterCodes2[CharacterCodes2["o"] = 111] = "o"; + CharacterCodes2[CharacterCodes2["p"] = 112] = "p"; + CharacterCodes2[CharacterCodes2["q"] = 113] = "q"; + CharacterCodes2[CharacterCodes2["r"] = 114] = "r"; + CharacterCodes2[CharacterCodes2["s"] = 115] = "s"; + CharacterCodes2[CharacterCodes2["t"] = 116] = "t"; + CharacterCodes2[CharacterCodes2["u"] = 117] = "u"; + CharacterCodes2[CharacterCodes2["v"] = 118] = "v"; + CharacterCodes2[CharacterCodes2["w"] = 119] = "w"; + CharacterCodes2[CharacterCodes2["x"] = 120] = "x"; + CharacterCodes2[CharacterCodes2["y"] = 121] = "y"; + CharacterCodes2[CharacterCodes2["z"] = 122] = "z"; + CharacterCodes2[CharacterCodes2["A"] = 65] = "A"; + CharacterCodes2[CharacterCodes2["B"] = 66] = "B"; + CharacterCodes2[CharacterCodes2["C"] = 67] = "C"; + CharacterCodes2[CharacterCodes2["D"] = 68] = "D"; + CharacterCodes2[CharacterCodes2["E"] = 69] = "E"; + CharacterCodes2[CharacterCodes2["F"] = 70] = "F"; + CharacterCodes2[CharacterCodes2["G"] = 71] = "G"; + CharacterCodes2[CharacterCodes2["H"] = 72] = "H"; + CharacterCodes2[CharacterCodes2["I"] = 73] = "I"; + CharacterCodes2[CharacterCodes2["J"] = 74] = "J"; + CharacterCodes2[CharacterCodes2["K"] = 75] = "K"; + CharacterCodes2[CharacterCodes2["L"] = 76] = "L"; + CharacterCodes2[CharacterCodes2["M"] = 77] = "M"; + CharacterCodes2[CharacterCodes2["N"] = 78] = "N"; + CharacterCodes2[CharacterCodes2["O"] = 79] = "O"; + CharacterCodes2[CharacterCodes2["P"] = 80] = "P"; + CharacterCodes2[CharacterCodes2["Q"] = 81] = "Q"; + CharacterCodes2[CharacterCodes2["R"] = 82] = "R"; + CharacterCodes2[CharacterCodes2["S"] = 83] = "S"; + CharacterCodes2[CharacterCodes2["T"] = 84] = "T"; + CharacterCodes2[CharacterCodes2["U"] = 85] = "U"; + CharacterCodes2[CharacterCodes2["V"] = 86] = "V"; + CharacterCodes2[CharacterCodes2["W"] = 87] = "W"; + CharacterCodes2[CharacterCodes2["X"] = 88] = "X"; + CharacterCodes2[CharacterCodes2["Y"] = 89] = "Y"; + CharacterCodes2[CharacterCodes2["Z"] = 90] = "Z"; + CharacterCodes2[CharacterCodes2["asterisk"] = 42] = "asterisk"; + CharacterCodes2[CharacterCodes2["backslash"] = 92] = "backslash"; + CharacterCodes2[CharacterCodes2["closeBrace"] = 125] = "closeBrace"; + CharacterCodes2[CharacterCodes2["closeBracket"] = 93] = "closeBracket"; + CharacterCodes2[CharacterCodes2["colon"] = 58] = "colon"; + CharacterCodes2[CharacterCodes2["comma"] = 44] = "comma"; + CharacterCodes2[CharacterCodes2["dot"] = 46] = "dot"; + CharacterCodes2[CharacterCodes2["doubleQuote"] = 34] = "doubleQuote"; + CharacterCodes2[CharacterCodes2["minus"] = 45] = "minus"; + CharacterCodes2[CharacterCodes2["openBrace"] = 123] = "openBrace"; + CharacterCodes2[CharacterCodes2["openBracket"] = 91] = "openBracket"; + CharacterCodes2[CharacterCodes2["plus"] = 43] = "plus"; + CharacterCodes2[CharacterCodes2["slash"] = 47] = "slash"; + CharacterCodes2[CharacterCodes2["formFeed"] = 12] = "formFeed"; + CharacterCodes2[CharacterCodes2["tab"] = 9] = "tab"; +})(CharacterCodes || (CharacterCodes = {})); + +// node_modules/jsonc-parser/lib/esm/impl/string-intern.js +var cachedSpaces = new Array(20).fill(0).map((_, index) => { + return " ".repeat(index); +}); +var maxCachedValues = 200; +var cachedBreakLinesWithSpaces = { + " ": { + "\n": new Array(maxCachedValues).fill(0).map((_, index) => { + return ` +` + " ".repeat(index); + }), + "\r": new Array(maxCachedValues).fill(0).map((_, index) => { + return "\r" + " ".repeat(index); + }), + "\r\n": new Array(maxCachedValues).fill(0).map((_, index) => { + return `\r +` + " ".repeat(index); + }) + }, + "\t": { + "\n": new Array(maxCachedValues).fill(0).map((_, index) => { + return ` +` + "\t".repeat(index); + }), + "\r": new Array(maxCachedValues).fill(0).map((_, index) => { + return "\r" + "\t".repeat(index); + }), + "\r\n": new Array(maxCachedValues).fill(0).map((_, index) => { + return `\r +` + "\t".repeat(index); + }) + } +}; +var supportedEols = [` +`, "\r", `\r +`]; + +// node_modules/jsonc-parser/lib/esm/impl/format.js +function format(documentText, range, options) { + let initialIndentLevel; + let formatText; + let formatTextStart; + let rangeStart; + let rangeEnd; + if (range) { + rangeStart = range.offset; + rangeEnd = rangeStart + range.length; + formatTextStart = rangeStart; + while (formatTextStart > 0 && !isEOL(documentText, formatTextStart - 1)) { + formatTextStart--; + } + let endOffset = rangeEnd; + while (endOffset < documentText.length && !isEOL(documentText, endOffset)) { + endOffset++; + } + formatText = documentText.substring(formatTextStart, endOffset); + initialIndentLevel = computeIndentLevel(formatText, options); + } else { + formatText = documentText; + initialIndentLevel = 0; + formatTextStart = 0; + rangeStart = 0; + rangeEnd = documentText.length; + } + const eol = getEOL(options, documentText); + const eolFastPathSupported = supportedEols.includes(eol); + let numberLineBreaks = 0; + let indentLevel = 0; + let indentValue; + if (options.insertSpaces) { + indentValue = cachedSpaces[options.tabSize || 4] ?? repeat(cachedSpaces[1], options.tabSize || 4); + } else { + indentValue = "\t"; + } + const indentType = indentValue === "\t" ? "\t" : " "; + let scanner = createScanner(formatText, false); + let hasError = false; + function newLinesAndIndent() { + if (numberLineBreaks > 1) { + return repeat(eol, numberLineBreaks) + repeat(indentValue, initialIndentLevel + indentLevel); + } + const amountOfSpaces = indentValue.length * (initialIndentLevel + indentLevel); + if (!eolFastPathSupported || amountOfSpaces > cachedBreakLinesWithSpaces[indentType][eol].length) { + return eol + repeat(indentValue, initialIndentLevel + indentLevel); + } + if (amountOfSpaces <= 0) { + return eol; + } + return cachedBreakLinesWithSpaces[indentType][eol][amountOfSpaces]; + } + function scanNext() { + let token = scanner.scan(); + numberLineBreaks = 0; + while (token === 15 || token === 14) { + if (token === 14 && options.keepLines) { + numberLineBreaks += 1; + } else if (token === 14) { + numberLineBreaks = 1; + } + token = scanner.scan(); + } + hasError = token === 16 || scanner.getTokenError() !== 0; + return token; + } + const editOperations = []; + function addEdit(text, startOffset, endOffset) { + if (!hasError && (!range || startOffset < rangeEnd && endOffset > rangeStart) && documentText.substring(startOffset, endOffset) !== text) { + editOperations.push({ offset: startOffset, length: endOffset - startOffset, content: text }); + } + } + let firstToken = scanNext(); + if (options.keepLines && numberLineBreaks > 0) { + addEdit(repeat(eol, numberLineBreaks), 0, 0); + } + if (firstToken !== 17) { + let firstTokenStart = scanner.getTokenOffset() + formatTextStart; + let initialIndent = indentValue.length * initialIndentLevel < 20 && options.insertSpaces ? cachedSpaces[indentValue.length * initialIndentLevel] : repeat(indentValue, initialIndentLevel); + addEdit(initialIndent, formatTextStart, firstTokenStart); + } + while (firstToken !== 17) { + let firstTokenEnd = scanner.getTokenOffset() + scanner.getTokenLength() + formatTextStart; + let secondToken = scanNext(); + let replaceContent = ""; + let needsLineBreak = false; + while (numberLineBreaks === 0 && (secondToken === 12 || secondToken === 13)) { + let commentTokenStart = scanner.getTokenOffset() + formatTextStart; + addEdit(cachedSpaces[1], firstTokenEnd, commentTokenStart); + firstTokenEnd = scanner.getTokenOffset() + scanner.getTokenLength() + formatTextStart; + needsLineBreak = secondToken === 12; + replaceContent = needsLineBreak ? newLinesAndIndent() : ""; + secondToken = scanNext(); + } + if (secondToken === 2) { + if (firstToken !== 1) { + indentLevel--; + } + if (options.keepLines && numberLineBreaks > 0 || !options.keepLines && firstToken !== 1) { + replaceContent = newLinesAndIndent(); + } else if (options.keepLines) { + replaceContent = cachedSpaces[1]; + } + } else if (secondToken === 4) { + if (firstToken !== 3) { + indentLevel--; + } + if (options.keepLines && numberLineBreaks > 0 || !options.keepLines && firstToken !== 3) { + replaceContent = newLinesAndIndent(); + } else if (options.keepLines) { + replaceContent = cachedSpaces[1]; + } + } else { + switch (firstToken) { + case 3: + case 1: + indentLevel++; + if (options.keepLines && numberLineBreaks > 0 || !options.keepLines) { + replaceContent = newLinesAndIndent(); + } else { + replaceContent = cachedSpaces[1]; + } + break; + case 5: + if (options.keepLines && numberLineBreaks > 0 || !options.keepLines) { + replaceContent = newLinesAndIndent(); + } else { + replaceContent = cachedSpaces[1]; + } + break; + case 12: + replaceContent = newLinesAndIndent(); + break; + case 13: + if (numberLineBreaks > 0) { + replaceContent = newLinesAndIndent(); + } else if (!needsLineBreak) { + replaceContent = cachedSpaces[1]; + } + break; + case 6: + if (options.keepLines && numberLineBreaks > 0) { + replaceContent = newLinesAndIndent(); + } else if (!needsLineBreak) { + replaceContent = cachedSpaces[1]; + } + break; + case 10: + if (options.keepLines && numberLineBreaks > 0) { + replaceContent = newLinesAndIndent(); + } else if (secondToken === 6 && !needsLineBreak) { + replaceContent = ""; + } + break; + case 7: + case 8: + case 9: + case 11: + case 2: + case 4: + if (options.keepLines && numberLineBreaks > 0) { + replaceContent = newLinesAndIndent(); + } else { + if ((secondToken === 12 || secondToken === 13) && !needsLineBreak) { + replaceContent = cachedSpaces[1]; + } else if (secondToken !== 5 && secondToken !== 17) { + hasError = true; + } + } + break; + case 16: + hasError = true; + break; + } + if (numberLineBreaks > 0 && (secondToken === 12 || secondToken === 13)) { + replaceContent = newLinesAndIndent(); + } + } + if (secondToken === 17) { + if (options.keepLines && numberLineBreaks > 0) { + replaceContent = newLinesAndIndent(); + } else { + replaceContent = options.insertFinalNewline ? eol : ""; + } + } + const secondTokenStart = scanner.getTokenOffset() + formatTextStart; + addEdit(replaceContent, firstTokenEnd, secondTokenStart); + firstToken = secondToken; + } + return editOperations; +} +function repeat(s, count) { + let result = ""; + for (let i = 0;i < count; i++) { + result += s; + } + return result; +} +function computeIndentLevel(content, options) { + let i = 0; + let nChars = 0; + const tabSize = options.tabSize || 4; + while (i < content.length) { + let ch = content.charAt(i); + if (ch === cachedSpaces[1]) { + nChars++; + } else if (ch === "\t") { + nChars += tabSize; + } else { + break; + } + i++; + } + return Math.floor(nChars / tabSize); +} +function getEOL(options, text) { + for (let i = 0;i < text.length; i++) { + const ch = text.charAt(i); + if (ch === "\r") { + if (i + 1 < text.length && text.charAt(i + 1) === ` +`) { + return `\r +`; + } + return "\r"; + } else if (ch === ` +`) { + return ` +`; + } + } + return options && options.eol || ` +`; +} +function isEOL(text, offset) { + return `\r +`.indexOf(text.charAt(offset)) !== -1; +} + +// node_modules/jsonc-parser/lib/esm/impl/parser.js +var ParseOptions; +(function(ParseOptions2) { + ParseOptions2.DEFAULT = { + allowTrailingComma: false + }; +})(ParseOptions || (ParseOptions = {})); +function parseTree(text, errors = [], options = ParseOptions.DEFAULT) { + let currentParent = { type: "array", offset: -1, length: -1, children: [], parent: undefined }; + function ensurePropertyComplete(endOffset) { + if (currentParent.type === "property") { + currentParent.length = endOffset - currentParent.offset; + currentParent = currentParent.parent; + } + } + function onValue(valueNode) { + currentParent.children.push(valueNode); + return valueNode; + } + const visitor = { + onObjectBegin: (offset) => { + currentParent = onValue({ type: "object", offset, length: -1, parent: currentParent, children: [] }); + }, + onObjectProperty: (name, offset, length) => { + currentParent = onValue({ type: "property", offset, length: -1, parent: currentParent, children: [] }); + currentParent.children.push({ type: "string", value: name, offset, length, parent: currentParent }); + }, + onObjectEnd: (offset, length) => { + ensurePropertyComplete(offset + length); + currentParent.length = offset + length - currentParent.offset; + currentParent = currentParent.parent; + ensurePropertyComplete(offset + length); + }, + onArrayBegin: (offset, length) => { + currentParent = onValue({ type: "array", offset, length: -1, parent: currentParent, children: [] }); + }, + onArrayEnd: (offset, length) => { + currentParent.length = offset + length - currentParent.offset; + currentParent = currentParent.parent; + ensurePropertyComplete(offset + length); + }, + onLiteralValue: (value, offset, length) => { + onValue({ type: getNodeType(value), offset, length, parent: currentParent, value }); + ensurePropertyComplete(offset + length); + }, + onSeparator: (sep, offset, length) => { + if (currentParent.type === "property") { + if (sep === ":") { + currentParent.colonOffset = offset; + } else if (sep === ",") { + ensurePropertyComplete(offset); + } + } + }, + onError: (error, offset, length) => { + errors.push({ error, offset, length }); + } + }; + visit(text, visitor, options); + const result = currentParent.children[0]; + if (result) { + delete result.parent; + } + return result; +} +function findNodeAtLocation(root, path) { + if (!root) { + return; + } + let node = root; + for (let segment of path) { + if (typeof segment === "string") { + if (node.type !== "object" || !Array.isArray(node.children)) { + return; + } + let found = false; + for (const propertyNode of node.children) { + if (Array.isArray(propertyNode.children) && propertyNode.children[0].value === segment && propertyNode.children.length === 2) { + node = propertyNode.children[1]; + found = true; + break; + } + } + if (!found) { + return; + } + } else { + const index = segment; + if (node.type !== "array" || index < 0 || !Array.isArray(node.children) || index >= node.children.length) { + return; + } + node = node.children[index]; + } + } + return node; +} +function visit(text, visitor, options = ParseOptions.DEFAULT) { + const _scanner = createScanner(text, false); + const _jsonPath = []; + let suppressedCallbacks = 0; + function toNoArgVisit(visitFunction) { + return visitFunction ? () => suppressedCallbacks === 0 && visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()) : () => true; + } + function toOneArgVisit(visitFunction) { + return visitFunction ? (arg) => suppressedCallbacks === 0 && visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()) : () => true; + } + function toOneArgVisitWithPath(visitFunction) { + return visitFunction ? (arg) => suppressedCallbacks === 0 && visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter(), () => _jsonPath.slice()) : () => true; + } + function toBeginVisit(visitFunction) { + return visitFunction ? () => { + if (suppressedCallbacks > 0) { + suppressedCallbacks++; + } else { + let cbReturn = visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter(), () => _jsonPath.slice()); + if (cbReturn === false) { + suppressedCallbacks = 1; + } + } + } : () => true; + } + function toEndVisit(visitFunction) { + return visitFunction ? () => { + if (suppressedCallbacks > 0) { + suppressedCallbacks--; + } + if (suppressedCallbacks === 0) { + visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()); + } + } : () => true; + } + const onObjectBegin = toBeginVisit(visitor.onObjectBegin), onObjectProperty = toOneArgVisitWithPath(visitor.onObjectProperty), onObjectEnd = toEndVisit(visitor.onObjectEnd), onArrayBegin = toBeginVisit(visitor.onArrayBegin), onArrayEnd = toEndVisit(visitor.onArrayEnd), onLiteralValue = toOneArgVisitWithPath(visitor.onLiteralValue), onSeparator = toOneArgVisit(visitor.onSeparator), onComment = toNoArgVisit(visitor.onComment), onError = toOneArgVisit(visitor.onError); + const disallowComments = options && options.disallowComments; + const allowTrailingComma = options && options.allowTrailingComma; + function scanNext() { + while (true) { + const token = _scanner.scan(); + switch (_scanner.getTokenError()) { + case 4: + handleError(14); + break; + case 5: + handleError(15); + break; + case 3: + handleError(13); + break; + case 1: + if (!disallowComments) { + handleError(11); + } + break; + case 2: + handleError(12); + break; + case 6: + handleError(16); + break; + } + switch (token) { + case 12: + case 13: + if (disallowComments) { + handleError(10); + } else { + onComment(); + } + break; + case 16: + handleError(1); + break; + case 15: + case 14: + break; + default: + return token; + } + } + } + function handleError(error, skipUntilAfter = [], skipUntil = []) { + onError(error); + if (skipUntilAfter.length + skipUntil.length > 0) { + let token = _scanner.getToken(); + while (token !== 17) { + if (skipUntilAfter.indexOf(token) !== -1) { + scanNext(); + break; + } else if (skipUntil.indexOf(token) !== -1) { + break; + } + token = scanNext(); + } + } + } + function parseString(isValue) { + const value = _scanner.getTokenValue(); + if (isValue) { + onLiteralValue(value); + } else { + onObjectProperty(value); + _jsonPath.push(value); + } + scanNext(); + return true; + } + function parseLiteral() { + switch (_scanner.getToken()) { + case 11: + const tokenValue = _scanner.getTokenValue(); + let value = Number(tokenValue); + if (isNaN(value)) { + handleError(2); + value = 0; + } + onLiteralValue(value); + break; + case 7: + onLiteralValue(null); + break; + case 8: + onLiteralValue(true); + break; + case 9: + onLiteralValue(false); + break; + default: + return false; + } + scanNext(); + return true; + } + function parseProperty() { + if (_scanner.getToken() !== 10) { + handleError(3, [], [2, 5]); + return false; + } + parseString(false); + if (_scanner.getToken() === 6) { + onSeparator(":"); + scanNext(); + if (!parseValue()) { + handleError(4, [], [2, 5]); + } + } else { + handleError(5, [], [2, 5]); + } + _jsonPath.pop(); + return true; + } + function parseObject() { + onObjectBegin(); + scanNext(); + let needsComma = false; + while (_scanner.getToken() !== 2 && _scanner.getToken() !== 17) { + if (_scanner.getToken() === 5) { + if (!needsComma) { + handleError(4, [], []); + } + onSeparator(","); + scanNext(); + if (_scanner.getToken() === 2 && allowTrailingComma) { + break; + } + } else if (needsComma) { + handleError(6, [], []); + } + if (!parseProperty()) { + handleError(4, [], [2, 5]); + } + needsComma = true; + } + onObjectEnd(); + if (_scanner.getToken() !== 2) { + handleError(7, [2], []); + } else { + scanNext(); + } + return true; + } + function parseArray() { + onArrayBegin(); + scanNext(); + let isFirstElement = true; + let needsComma = false; + while (_scanner.getToken() !== 4 && _scanner.getToken() !== 17) { + if (_scanner.getToken() === 5) { + if (!needsComma) { + handleError(4, [], []); + } + onSeparator(","); + scanNext(); + if (_scanner.getToken() === 4 && allowTrailingComma) { + break; + } + } else if (needsComma) { + handleError(6, [], []); + } + if (isFirstElement) { + _jsonPath.push(0); + isFirstElement = false; + } else { + _jsonPath[_jsonPath.length - 1]++; + } + if (!parseValue()) { + handleError(4, [], [4, 5]); + } + needsComma = true; + } + onArrayEnd(); + if (!isFirstElement) { + _jsonPath.pop(); + } + if (_scanner.getToken() !== 4) { + handleError(8, [4], []); + } else { + scanNext(); + } + return true; + } + function parseValue() { + switch (_scanner.getToken()) { + case 3: + return parseArray(); + case 1: + return parseObject(); + case 10: + return parseString(true); + default: + return parseLiteral(); + } + } + scanNext(); + if (_scanner.getToken() === 17) { + if (options.allowEmptyContent) { + return true; + } + handleError(4, [], []); + return false; + } + if (!parseValue()) { + handleError(4, [], []); + return false; + } + if (_scanner.getToken() !== 17) { + handleError(9, [], []); + } + return true; +} +function getNodeType(value) { + switch (typeof value) { + case "boolean": + return "boolean"; + case "number": + return "number"; + case "string": + return "string"; + case "object": { + if (!value) { + return "null"; + } else if (Array.isArray(value)) { + return "array"; + } + return "object"; + } + default: + return "null"; + } +} + +// node_modules/jsonc-parser/lib/esm/impl/edit.js +function setProperty(text, originalPath, value, options) { + const path = originalPath.slice(); + const errors = []; + const root = parseTree(text, errors); + let parent = undefined; + let lastSegment = undefined; + while (path.length > 0) { + lastSegment = path.pop(); + parent = findNodeAtLocation(root, path); + if (parent === undefined && value !== undefined) { + if (typeof lastSegment === "string") { + value = { [lastSegment]: value }; + } else { + value = [value]; + } + } else { + break; + } + } + if (!parent) { + if (value === undefined) { + throw new Error("Can not delete in empty document"); + } + return withFormatting(text, { offset: root ? root.offset : 0, length: root ? root.length : 0, content: JSON.stringify(value) }, options); + } else if (parent.type === "object" && typeof lastSegment === "string" && Array.isArray(parent.children)) { + const existing = findNodeAtLocation(parent, [lastSegment]); + if (existing !== undefined) { + if (value === undefined) { + if (!existing.parent) { + throw new Error("Malformed AST"); + } + const propertyIndex = parent.children.indexOf(existing.parent); + let removeBegin; + let removeEnd = existing.parent.offset + existing.parent.length; + if (propertyIndex > 0) { + let previous = parent.children[propertyIndex - 1]; + removeBegin = previous.offset + previous.length; + } else { + removeBegin = parent.offset + 1; + if (parent.children.length > 1) { + let next = parent.children[1]; + removeEnd = next.offset; + } + } + return withFormatting(text, { offset: removeBegin, length: removeEnd - removeBegin, content: "" }, options); + } else { + return withFormatting(text, { offset: existing.offset, length: existing.length, content: JSON.stringify(value) }, options); + } + } else { + if (value === undefined) { + return []; + } + const newProperty = `${JSON.stringify(lastSegment)}: ${JSON.stringify(value)}`; + const index = options.getInsertionIndex ? options.getInsertionIndex(parent.children.map((p) => p.children[0].value)) : parent.children.length; + let edit; + if (index > 0) { + let previous = parent.children[index - 1]; + edit = { offset: previous.offset + previous.length, length: 0, content: "," + newProperty }; + } else if (parent.children.length === 0) { + edit = { offset: parent.offset + 1, length: 0, content: newProperty }; + } else { + edit = { offset: parent.offset + 1, length: 0, content: newProperty + "," }; + } + return withFormatting(text, edit, options); + } + } else if (parent.type === "array" && typeof lastSegment === "number" && Array.isArray(parent.children)) { + const insertIndex = lastSegment; + if (insertIndex === -1) { + const newProperty = `${JSON.stringify(value)}`; + let edit; + if (parent.children.length === 0) { + edit = { offset: parent.offset + 1, length: 0, content: newProperty }; + } else { + const previous = parent.children[parent.children.length - 1]; + edit = { offset: previous.offset + previous.length, length: 0, content: "," + newProperty }; + } + return withFormatting(text, edit, options); + } else if (value === undefined && parent.children.length >= 0) { + const removalIndex = lastSegment; + const toRemove = parent.children[removalIndex]; + let edit; + if (parent.children.length === 1) { + edit = { offset: parent.offset + 1, length: parent.length - 2, content: "" }; + } else if (parent.children.length - 1 === removalIndex) { + let previous = parent.children[removalIndex - 1]; + let offset = previous.offset + previous.length; + let parentEndOffset = parent.offset + parent.length; + edit = { offset, length: parentEndOffset - 2 - offset, content: "" }; + } else { + edit = { offset: toRemove.offset, length: parent.children[removalIndex + 1].offset - toRemove.offset, content: "" }; + } + return withFormatting(text, edit, options); + } else if (value !== undefined) { + let edit; + const newProperty = `${JSON.stringify(value)}`; + if (!options.isArrayInsertion && parent.children.length > lastSegment) { + const toModify = parent.children[lastSegment]; + edit = { offset: toModify.offset, length: toModify.length, content: newProperty }; + } else if (parent.children.length === 0 || lastSegment === 0) { + edit = { offset: parent.offset + 1, length: 0, content: parent.children.length === 0 ? newProperty : newProperty + "," }; + } else { + const index = lastSegment > parent.children.length ? parent.children.length : lastSegment; + const previous = parent.children[index - 1]; + edit = { offset: previous.offset + previous.length, length: 0, content: "," + newProperty }; + } + return withFormatting(text, edit, options); + } else { + throw new Error(`Can not ${value === undefined ? "remove" : options.isArrayInsertion ? "insert" : "modify"} Array index ${insertIndex} as length is not sufficient`); + } + } else { + throw new Error(`Can not add ${typeof lastSegment !== "number" ? "index" : "property"} to parent of type ${parent.type}`); + } +} +function withFormatting(text, edit, options) { + if (!options.formattingOptions) { + return [edit]; + } + let newText = applyEdit(text, edit); + let begin = edit.offset; + let end = edit.offset + edit.content.length; + if (edit.length === 0 || edit.content.length === 0) { + while (begin > 0 && !isEOL(newText, begin - 1)) { + begin--; + } + while (end < newText.length && !isEOL(newText, end)) { + end++; + } + } + const edits = format(newText, { offset: begin, length: end - begin }, { ...options.formattingOptions, keepLines: false }); + for (let i = edits.length - 1;i >= 0; i--) { + const edit2 = edits[i]; + newText = applyEdit(newText, edit2); + begin = Math.min(begin, edit2.offset); + end = Math.max(end, edit2.offset + edit2.length); + end += edit2.content.length - edit2.length; + } + const editLength = text.length - (newText.length - end) - begin; + return [{ offset: begin, length: editLength, content: newText.substring(begin, end) }]; +} +function applyEdit(text, edit) { + return text.substring(0, edit.offset) + edit.content + text.substring(edit.offset + edit.length); +} + +// node_modules/jsonc-parser/lib/esm/main.js +var ScanError; +(function(ScanError2) { + ScanError2[ScanError2["None"] = 0] = "None"; + ScanError2[ScanError2["UnexpectedEndOfComment"] = 1] = "UnexpectedEndOfComment"; + ScanError2[ScanError2["UnexpectedEndOfString"] = 2] = "UnexpectedEndOfString"; + ScanError2[ScanError2["UnexpectedEndOfNumber"] = 3] = "UnexpectedEndOfNumber"; + ScanError2[ScanError2["InvalidUnicode"] = 4] = "InvalidUnicode"; + ScanError2[ScanError2["InvalidEscapeCharacter"] = 5] = "InvalidEscapeCharacter"; + ScanError2[ScanError2["InvalidCharacter"] = 6] = "InvalidCharacter"; +})(ScanError || (ScanError = {})); +var SyntaxKind; +(function(SyntaxKind2) { + SyntaxKind2[SyntaxKind2["OpenBraceToken"] = 1] = "OpenBraceToken"; + SyntaxKind2[SyntaxKind2["CloseBraceToken"] = 2] = "CloseBraceToken"; + SyntaxKind2[SyntaxKind2["OpenBracketToken"] = 3] = "OpenBracketToken"; + SyntaxKind2[SyntaxKind2["CloseBracketToken"] = 4] = "CloseBracketToken"; + SyntaxKind2[SyntaxKind2["CommaToken"] = 5] = "CommaToken"; + SyntaxKind2[SyntaxKind2["ColonToken"] = 6] = "ColonToken"; + SyntaxKind2[SyntaxKind2["NullKeyword"] = 7] = "NullKeyword"; + SyntaxKind2[SyntaxKind2["TrueKeyword"] = 8] = "TrueKeyword"; + SyntaxKind2[SyntaxKind2["FalseKeyword"] = 9] = "FalseKeyword"; + SyntaxKind2[SyntaxKind2["StringLiteral"] = 10] = "StringLiteral"; + SyntaxKind2[SyntaxKind2["NumericLiteral"] = 11] = "NumericLiteral"; + SyntaxKind2[SyntaxKind2["LineCommentTrivia"] = 12] = "LineCommentTrivia"; + SyntaxKind2[SyntaxKind2["BlockCommentTrivia"] = 13] = "BlockCommentTrivia"; + SyntaxKind2[SyntaxKind2["LineBreakTrivia"] = 14] = "LineBreakTrivia"; + SyntaxKind2[SyntaxKind2["Trivia"] = 15] = "Trivia"; + SyntaxKind2[SyntaxKind2["Unknown"] = 16] = "Unknown"; + SyntaxKind2[SyntaxKind2["EOF"] = 17] = "EOF"; +})(SyntaxKind || (SyntaxKind = {})); +var parseTree2 = parseTree; +var ParseErrorCode; +(function(ParseErrorCode2) { + ParseErrorCode2[ParseErrorCode2["InvalidSymbol"] = 1] = "InvalidSymbol"; + ParseErrorCode2[ParseErrorCode2["InvalidNumberFormat"] = 2] = "InvalidNumberFormat"; + ParseErrorCode2[ParseErrorCode2["PropertyNameExpected"] = 3] = "PropertyNameExpected"; + ParseErrorCode2[ParseErrorCode2["ValueExpected"] = 4] = "ValueExpected"; + ParseErrorCode2[ParseErrorCode2["ColonExpected"] = 5] = "ColonExpected"; + ParseErrorCode2[ParseErrorCode2["CommaExpected"] = 6] = "CommaExpected"; + ParseErrorCode2[ParseErrorCode2["CloseBraceExpected"] = 7] = "CloseBraceExpected"; + ParseErrorCode2[ParseErrorCode2["CloseBracketExpected"] = 8] = "CloseBracketExpected"; + ParseErrorCode2[ParseErrorCode2["EndOfFileExpected"] = 9] = "EndOfFileExpected"; + ParseErrorCode2[ParseErrorCode2["InvalidCommentToken"] = 10] = "InvalidCommentToken"; + ParseErrorCode2[ParseErrorCode2["UnexpectedEndOfComment"] = 11] = "UnexpectedEndOfComment"; + ParseErrorCode2[ParseErrorCode2["UnexpectedEndOfString"] = 12] = "UnexpectedEndOfString"; + ParseErrorCode2[ParseErrorCode2["UnexpectedEndOfNumber"] = 13] = "UnexpectedEndOfNumber"; + ParseErrorCode2[ParseErrorCode2["InvalidUnicode"] = 14] = "InvalidUnicode"; + ParseErrorCode2[ParseErrorCode2["InvalidEscapeCharacter"] = 15] = "InvalidEscapeCharacter"; + ParseErrorCode2[ParseErrorCode2["InvalidCharacter"] = 16] = "InvalidCharacter"; +})(ParseErrorCode || (ParseErrorCode = {})); +function modify(text, path, value, options) { + return setProperty(text, path, value, options); +} +function applyEdits(text, edits) { + let sortedEdits = edits.slice(0).sort((a, b) => { + const diff = a.offset - b.offset; + if (diff === 0) { + return a.length - b.length; + } + return diff; + }); + let lastModifiedOffset = text.length; + for (let i = sortedEdits.length - 1;i >= 0; i--) { + let e = sortedEdits[i]; + if (e.offset + e.length <= lastModifiedOffset) { + text = applyEdit(text, e); + } else { + throw new Error("Overlapping edit"); + } + lastModifiedOffset = e.offset; + } + return text; +} + +// src/writer.ts +function getOmoDir() { + return join2(homedir2(), ".omo"); +} function getOpenCodeConfigDir() { const ocxConfigDir = process.env.OPENCODE_CONFIG_DIR; if (ocxConfigDir) { @@ -157,19 +1444,52 @@ async function writeConfigAtomic(filePath, content) { writeFileSync2(tempPath, content, "utf-8"); renameSync(tempPath, filePath); } -async function readFileHash(filePath) { - try { - if (!existsSync2(filePath)) { - return null; +function resolveOmoConfigPath(omoDir) { + const jsoncPath = join2(omoDir, "omo.jsonc"); + const jsonPath = join2(omoDir, "omo.json"); + return !existsSync2(jsoncPath) && existsSync2(jsonPath) ? jsonPath : jsoncPath; +} +function withOpencodeBlock(path, existing, block) { + const errors = []; + const root = parseTree2(existing, errors, { allowTrailingComma: true }); + if (root && (root.type !== "object" || errors.length > 0)) { + throw new Error(`${path} is not a valid JSONC object; fix or remove it to sync`); + } + const edits = modify(existing, ["[opencode]"], block, { + formattingOptions: { insertSpaces: true, tabSize: 2, eol: ` +` } + }); + return applyEdits(existing, edits); +} +function readIfExists(path) { + return existsSync2(path) ? readFileSync2(path, "utf-8") : null; +} +async function applyBundle(bundle, dirs) { + const writes = []; + const add = (path, render) => { + const existing = readIfExists(path); + writes.push({ path, existing, content: render(existing ?? "") }); + }; + const { opencode, ohMyOpencode, ohMyOpenCodeSlim } = bundle; + add(resolveOpencodeConfigPath(dirs.configDir), () => JSON.stringify(opencode, null, 2)); + if (ohMyOpencode) { + const omoPath = resolveOmoConfigPath(dirs.omoDir); + add(omoPath, (existing) => withOpencodeBlock(omoPath, existing, ohMyOpencode)); + } + if (ohMyOpenCodeSlim) { + add(join2(dirs.configDir, "oh-my-opencode-slim.jsonc"), () => JSON.stringify(ohMyOpenCodeSlim, null, 2)); + } + if (ohMyOpencode) { + mkdirSync2(dirs.omoDir, { recursive: true }); + } + const changed = []; + for (const { path, existing, content } of writes) { + await writeConfigAtomic(path, content); + if (existing !== null && existing !== content) { + changed.push(path); } - const content = readFileSync2(filePath, "utf-8"); - const hash = createHash("sha256"); - hash.update(content); - return hash.digest("hex"); - } catch (error) { - console.error(`[cliproxyapi-sync] Failed to hash ${filePath}:`, error); - return null; } + return changed; } // src/index.ts @@ -194,21 +1514,13 @@ var ConfigSyncPlugin = async (ctx) => { return {}; } const configDir = getOpenCodeConfigDir(); - const opencodeConfigPath = resolveOpencodeConfigPath(configDir); - const ohMyConfigPath = join3(configDir, "oh-my-opencode.json"); - const ohMySlimConfigPath = join3(configDir, "oh-my-opencode-slim.json"); - const oldOpencodeHash = await readFileHash(opencodeConfigPath); - await writeConfigAtomic(opencodeConfigPath, JSON.stringify(bundle.opencode, null, 2)); - if (bundle.ohMyOpencode) { - await writeConfigAtomic(ohMyConfigPath, JSON.stringify(bundle.ohMyOpencode, null, 2)); - } - if (bundle.ohMyOpenCodeSlim) { - await writeConfigAtomic(ohMySlimConfigPath, JSON.stringify(bundle.ohMyOpenCodeSlim, null, 2)); - } - const newOpencodeHash = await readFileHash(opencodeConfigPath); + const changed = await applyBundle(bundle, { + configDir, + omoDir: getOmoDir() + }); config.lastKnownVersion = bundle.version; savePluginConfig(config); - if (oldOpencodeHash !== null && oldOpencodeHash !== newOpencodeHash) { + if (changed.includes(resolveOpencodeConfigPath(configDir))) { setTimeout(() => { ctx.client.tui.showToast({ body: { diff --git a/package.json b/package.json index 917e4e1..5314647 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-cliproxyapi-sync", - "version": "1.4.0", + "version": "1.5.0", "description": "Auto-sync OpenCode configs from CLIProxyAPI Dashboard", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -37,5 +37,8 @@ }, "bugs": { "url": "https://github.com/itsmylife44/opencode-cliproxyapi-sync/issues" + }, + "dependencies": { + "jsonc-parser": "^3.3.1" } } diff --git a/src/index.ts b/src/index.ts index bd78c4c..afd0822 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,11 @@ import type { Plugin } from "@opencode-ai/plugin"; -import { join } from "path"; import { loadPluginConfig, savePluginConfig } from "./config"; import { checkVersion, fetchBundle } from "./sync"; import { + applyBundle, + getOmoDir, getOpenCodeConfigDir, resolveOpencodeConfigPath, - writeConfigAtomic, - readFileHash, } from "./writer"; const ConfigSyncPlugin: Plugin = async (ctx) => { @@ -41,35 +40,15 @@ const ConfigSyncPlugin: Plugin = async (ctx) => { } const configDir = getOpenCodeConfigDir(); - const opencodeConfigPath = resolveOpencodeConfigPath(configDir); - const ohMyConfigPath = join(configDir, "oh-my-opencode.json"); - const ohMySlimConfigPath = join(configDir, "oh-my-opencode-slim.json"); - - const oldOpencodeHash = await readFileHash(opencodeConfigPath); - - await writeConfigAtomic( - opencodeConfigPath, - JSON.stringify(bundle.opencode, null, 2) - ); - if (bundle.ohMyOpencode) { - await writeConfigAtomic( - ohMyConfigPath, - JSON.stringify(bundle.ohMyOpencode, null, 2) - ); - } - if (bundle.ohMyOpenCodeSlim) { - await writeConfigAtomic( - ohMySlimConfigPath, - JSON.stringify(bundle.ohMyOpenCodeSlim, null, 2) - ); - } - - const newOpencodeHash = await readFileHash(opencodeConfigPath); + const changed = await applyBundle(bundle, { + configDir, + omoDir: getOmoDir(), + }); config.lastKnownVersion = bundle.version; savePluginConfig(config); - if (oldOpencodeHash !== null && oldOpencodeHash !== newOpencodeHash) { + if (changed.includes(resolveOpencodeConfigPath(configDir))) { setTimeout(() => { ctx.client.tui.showToast({ body: { diff --git a/src/writer.test.ts b/src/writer.test.ts new file mode 100644 index 0000000..ec67aa2 --- /dev/null +++ b/src/writer.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { parse, type ParseError } from "jsonc-parser"; +import type { Bundle } from "./sync"; +import { applyBundle } from "./writer"; + +const omoConfig = { agents: { sisyphus: { model: "cliproxyapi/claude-opus-4-6" } } }; + +function bundle(overrides: Partial = {}): Bundle { + return { + version: "v1", + opencode: { $schema: "https://opencode.ai/config.json" }, + ohMyOpencode: null, + ohMyOpenCodeSlim: null, + ...overrides, + }; +} + +describe("applyBundle", () => { + let configDir: string; + let omoDir: string; + + beforeEach(() => { + const root = mkdtempSync(join(tmpdir(), "cliproxy-sync-")); + configDir = join(root, "opencode"); + omoDir = join(root, ".omo"); + mkdirSync(configDir); + }); + + it("writes opencode.json", async () => { + await applyBundle(bundle(), { configDir, omoDir }); + const written = JSON.parse(readFileSync(join(configDir, "opencode.json"), "utf-8")); + expect(written).toEqual({ $schema: "https://opencode.ai/config.json" }); + }); + + it("replaces only the [opencode] block of an existing omo.jsonc", async () => { + mkdirSync(omoDir); + const head = `// OMO configuration\n{\n "$schema": "https://example.com/omo.schema.json",\n "[opencode]": `; + const tail = `,\n // native harness\n "[native]": { "agents": { "x": { "model": "a/b" } } }, /* keep */\n "models": {}\n}\n`; + writeFileSync(join(omoDir, "omo.jsonc"), `${head}{ "agents": { "old": {} } }${tail}`); + + await applyBundle(bundle({ ohMyOpencode: omoConfig }), { configDir, omoDir }); + + const result = readFileSync(join(omoDir, "omo.jsonc"), "utf-8"); + expect(result.startsWith(head)).toBe(true); + expect(result.endsWith(tail)).toBe(true); + expect(parse(result)["[opencode]"]).toEqual(omoConfig); + }); + + it("creates ~/.omo/omo.jsonc with an [opencode] block when missing", async () => { + await applyBundle(bundle({ ohMyOpencode: omoConfig }), { configDir, omoDir }); + + const errors: ParseError[] = []; + const result = parse(readFileSync(join(omoDir, "omo.jsonc"), "utf-8"), errors); + expect(errors).toEqual([]); + expect(result).toEqual({ "[opencode]": omoConfig }); + }); + + it("writes into omo.json when that is the file OMO reads", async () => { + mkdirSync(omoDir); + writeFileSync(join(omoDir, "omo.json"), `{ "models": {} }`); + + await applyBundle(bundle({ ohMyOpencode: omoConfig }), { configDir, omoDir }); + + expect(existsSync(join(omoDir, "omo.jsonc"))).toBe(false); + expect(parse(readFileSync(join(omoDir, "omo.json"), "utf-8"))).toEqual({ + models: {}, + "[opencode]": omoConfig, + }); + }); + + for (const corrupt of ["{ broken", `{"a":1,,}`, "[]"]) { + it(`refuses to touch any file when omo.jsonc is ${corrupt}`, async () => { + mkdirSync(omoDir); + writeFileSync(join(omoDir, "omo.jsonc"), corrupt); + + await expect( + applyBundle(bundle({ ohMyOpencode: omoConfig }), { configDir, omoDir }) + ).rejects.toThrow(/omo\.jsonc/); + + expect(readFileSync(join(omoDir, "omo.jsonc"), "utf-8")).toBe(corrupt); + expect(existsSync(join(configDir, "opencode.json"))).toBe(false); + }); + } + + it("writes slim config to oh-my-opencode-slim.jsonc", async () => { + const slim = { agents: { orchestrator: { model: "cliproxyapi/claude-opus-4-6" } } }; + await applyBundle(bundle({ ohMyOpenCodeSlim: slim }), { configDir, omoDir }); + + const written = parse(readFileSync(join(configDir, "oh-my-opencode-slim.jsonc"), "utf-8")); + expect(written).toEqual(slim); + }); + + it("leaves legacy oh-my-opencode.json and oh-my-opencode-slim.json untouched", async () => { + writeFileSync(join(configDir, "oh-my-opencode.json"), "legacy omo"); + writeFileSync(join(configDir, "oh-my-opencode-slim.json"), "legacy slim"); + + await applyBundle( + bundle({ ohMyOpencode: omoConfig, ohMyOpenCodeSlim: { agents: {} } }), + { configDir, omoDir } + ); + + expect(readFileSync(join(configDir, "oh-my-opencode.json"), "utf-8")).toBe("legacy omo"); + expect(readFileSync(join(configDir, "oh-my-opencode-slim.json"), "utf-8")).toBe("legacy slim"); + }); + + it("reports which existing files changed", async () => { + const first = await applyBundle(bundle({ ohMyOpencode: omoConfig }), { configDir, omoDir }); + expect(first).toEqual([]); + + const changed = await applyBundle( + bundle({ opencode: { plugin: [] }, ohMyOpencode: omoConfig }), + { configDir, omoDir } + ); + expect(changed).toEqual([join(configDir, "opencode.json")]); + }); +}); diff --git a/src/writer.ts b/src/writer.ts index b422811..1fd8b3f 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -1,7 +1,29 @@ -import { readFileSync, writeFileSync, existsSync, renameSync } from "fs"; -import { createHash } from "crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "fs"; import { homedir } from "os"; import { join } from "path"; +// ESM build: bundling the package's default UMD entry leaves a broken require("./impl/format") in dist. +import { + applyEdits, + modify, + parseTree, + type ParseError, +} from "jsonc-parser/lib/esm/main.js"; +import type { Bundle } from "./sync"; + +export interface SyncDirs { + configDir: string; + omoDir: string; +} + +export function getOmoDir(): string { + return join(homedir(), ".omo"); +} export function getOpenCodeConfigDir(): string { // OCX sets OPENCODE_CONFIG_DIR when using profiles (e.g. ~/.config/opencode/profiles/spam/) @@ -44,18 +66,74 @@ export async function writeConfigAtomic( renameSync(tempPath, filePath); } -export async function readFileHash(filePath: string): Promise { - try { - if (!existsSync(filePath)) { - return null; - } +/** OMO reads omo.jsonc, falling back to omo.json; write whichever it reads. */ +function resolveOmoConfigPath(omoDir: string): string { + const jsoncPath = join(omoDir, "omo.jsonc"); + const jsonPath = join(omoDir, "omo.json"); + return !existsSync(jsoncPath) && existsSync(jsonPath) ? jsonPath : jsoncPath; +} - const content = readFileSync(filePath, "utf-8"); - const hash = createHash("sha256"); - hash.update(content); - return hash.digest("hex"); - } catch (error) { - console.error(`[cliproxyapi-sync] Failed to hash ${filePath}:`, error); - return null; +function withOpencodeBlock( + path: string, + existing: string, + block: Record +): string { + const errors: ParseError[] = []; + const root = parseTree(existing, errors, { allowTrailingComma: true }); + // Empty or comment-only files have no root; modify() creates the object. + if (root && (root.type !== "object" || errors.length > 0)) { + throw new Error(`${path} is not a valid JSONC object; fix or remove it to sync`); + } + const edits = modify(existing, ["[opencode]"], block, { + formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, + }); + return applyEdits(existing, edits); +} + +function readIfExists(path: string): string | null { + return existsSync(path) ? readFileSync(path, "utf-8") : null; +} + +/** + * Writes every config in the bundle. Legacy oh-my-opencode(-slim).json files are never touched. + * Every file is rendered before any is written, so a bad omo.jsonc aborts the whole sync. + * Returns the paths of files that existed before and whose content changed. + */ +export async function applyBundle( + bundle: Bundle, + dirs: SyncDirs +): Promise { + const writes: Array<{ path: string; existing: string | null; content: string }> = []; + const add = (path: string, render: (existing: string) => string) => { + const existing = readIfExists(path); + writes.push({ path, existing, content: render(existing ?? "") }); + }; + + const { opencode, ohMyOpencode, ohMyOpenCodeSlim } = bundle; + add(resolveOpencodeConfigPath(dirs.configDir), () => + JSON.stringify(opencode, null, 2) + ); + if (ohMyOpencode) { + const omoPath = resolveOmoConfigPath(dirs.omoDir); + add(omoPath, (existing) => + withOpencodeBlock(omoPath, existing, ohMyOpencode) + ); + } + if (ohMyOpenCodeSlim) { + add(join(dirs.configDir, "oh-my-opencode-slim.jsonc"), () => + JSON.stringify(ohMyOpenCodeSlim, null, 2) + ); + } + + if (ohMyOpencode) { + mkdirSync(dirs.omoDir, { recursive: true }); + } + const changed: string[] = []; + for (const { path, existing, content } of writes) { + await writeConfigAtomic(path, content); + if (existing !== null && existing !== content) { + changed.push(path); + } } + return changed; }