opencode-mcp-triage 0.7.0
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/.opencode/commands/mcp-triage.md +4 -0
- package/.opencode/commands/triage.md +5 -0
- package/.opencode/plugins/opencode-mcp-triage.ts +1 -0
- package/README.md +242 -0
- package/bin/opencode-mcp-triage.cjs +764 -0
- package/package.json +60 -0
- package/postinstall.cjs +27 -0
- package/src/config.ts +245 -0
- package/src/index.ts +425 -0
- package/src/lock.ts +42 -0
- package/src/triage.ts +121 -0
- package/src/types.ts +63 -0
- package/src/writer.ts +468 -0
package/src/writer.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config file writer for disabling MCP tools in the main agent.
|
|
3
|
+
*
|
|
4
|
+
* Purpose: On plugin init, writes "servername_*": false entries into the
|
|
5
|
+
* project-level opencode.jsonc tools block. This disables MCP tools in the
|
|
6
|
+
* main session (saves tokens) while subagents re-enable them via tool scoping.
|
|
7
|
+
*
|
|
8
|
+
* Why string manipulation instead of JSON parse → modify → stringify?
|
|
9
|
+
* - opencode.jsonc uses JSONC (comments, trailing commas)
|
|
10
|
+
* - JSON.parse strips comments — we'd lose user comments on re-write
|
|
11
|
+
* - String manipulation preserves comments and formatting
|
|
12
|
+
*
|
|
13
|
+
* Trade-off: more fragile than proper JSON manipulation. We compensate with:
|
|
14
|
+
* - Stripping comments before regex matching
|
|
15
|
+
* - Position mapping between stripped and original strings
|
|
16
|
+
* - Validation before writing (parse check catches broken output)
|
|
17
|
+
*
|
|
18
|
+
* IMPORTANT: This only writes to the project-level config, never global.
|
|
19
|
+
* Project config takes priority in OpenCode's config resolution.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises"
|
|
23
|
+
import { join, dirname, sep } from "node:path"
|
|
24
|
+
import { homedir } from "node:os"
|
|
25
|
+
import { readLock, writeLock } from "./lock.js"
|
|
26
|
+
import type { McpServer, Subagent } from "./types.js"
|
|
27
|
+
|
|
28
|
+
/** Max config file size: 1MB — prevents memory exhaustion */
|
|
29
|
+
const MAX_CONFIG_SIZE = 1024 * 1024
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Strips UTF-8 BOM (Byte Order Mark) from string.
|
|
33
|
+
* BOM is the 3-byte sequence: EF BB BF (U+FEFF)
|
|
34
|
+
*/
|
|
35
|
+
function stripBOM(s: string): string {
|
|
36
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xfeff) {
|
|
37
|
+
return s.slice(1)
|
|
38
|
+
}
|
|
39
|
+
return s
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validates a file path against path traversal attacks.
|
|
44
|
+
* Rejects paths containing null bytes or .. path segments.
|
|
45
|
+
*/
|
|
46
|
+
function validatePath(path: string): boolean {
|
|
47
|
+
if (path.includes("\0")) return false
|
|
48
|
+
const segments = path.split(sep)
|
|
49
|
+
if (segments.includes("..")) return false
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Writes a file, creating parent directories as needed.
|
|
55
|
+
*/
|
|
56
|
+
async function safeWriteFile(path: string, content: string): Promise<void> {
|
|
57
|
+
await mkdir(dirname(path), { recursive: true })
|
|
58
|
+
await writeFile(path, content, "utf-8")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ensures all MCP server tools are disabled in the main agent's tools config.
|
|
63
|
+
*
|
|
64
|
+
* Idempotent: checks if entries already exist before writing.
|
|
65
|
+
* Only writes when missing entries are found.
|
|
66
|
+
*
|
|
67
|
+
* The disable pattern is "servername_*": false — OpenCode uses this glob
|
|
68
|
+
* pattern to match all tools from a given MCP server.
|
|
69
|
+
*
|
|
70
|
+
* @returns true if file was modified, false if already disabled or error
|
|
71
|
+
*/
|
|
72
|
+
export async function ensureToolsDisabled(
|
|
73
|
+
directory: string,
|
|
74
|
+
mcpServers: string[]
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
if (mcpServers.length === 0) return false
|
|
77
|
+
|
|
78
|
+
const resolved = await findProjectConfigPath(directory)
|
|
79
|
+
if (!resolved) return false
|
|
80
|
+
|
|
81
|
+
const { path: configPath, exists } = resolved
|
|
82
|
+
|
|
83
|
+
let raw: string
|
|
84
|
+
if (exists) {
|
|
85
|
+
try {
|
|
86
|
+
raw = await readFile(configPath, "utf-8")
|
|
87
|
+
// Size limit: reject files > 1MB
|
|
88
|
+
if (raw.length > MAX_CONFIG_SIZE) return false
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// No project config exists — start with empty object
|
|
94
|
+
raw = "{}"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Strip BOM (Windows editors may prepend it)
|
|
98
|
+
raw = stripBOM(raw)
|
|
99
|
+
|
|
100
|
+
// Strip comments before checking — comments like // "github_*": false
|
|
101
|
+
// should not count as actual disable entries
|
|
102
|
+
const stripped = stripJsonComments(raw)
|
|
103
|
+
const missing = mcpServers.filter((name) => {
|
|
104
|
+
const regex = new RegExp(
|
|
105
|
+
`"${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_\\*"\\s*:\\s*false`
|
|
106
|
+
)
|
|
107
|
+
return !regex.test(stripped)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// All servers already disabled — nothing to do
|
|
111
|
+
if (missing.length === 0) return false
|
|
112
|
+
|
|
113
|
+
// Build the new entries as JSON text (one per line for readability)
|
|
114
|
+
const newEntries = missing.map((name) => `"${name}_*": false`).join(",\n ")
|
|
115
|
+
|
|
116
|
+
// Use stripped version to find "tools" — avoids matching comments
|
|
117
|
+
const toolsMatch = stripped.match(/"tools"\s*:\s*\{/)
|
|
118
|
+
let modified: string
|
|
119
|
+
|
|
120
|
+
if (toolsMatch) {
|
|
121
|
+
// "tools" block exists — insert entries after opening brace
|
|
122
|
+
// Map position from stripped string back to original (comments shift positions)
|
|
123
|
+
const insertPos = mapStrippedPosition(raw, stripped, toolsMatch.index! + toolsMatch[0].length)
|
|
124
|
+
const prefix = raw.slice(0, insertPos)
|
|
125
|
+
const suffix = raw.slice(insertPos)
|
|
126
|
+
|
|
127
|
+
// Check if tools block is empty {} (skip comments to find real closing brace)
|
|
128
|
+
const suffixStripped = stripJsonComments(suffix)
|
|
129
|
+
const emptyBlockMatch = suffixStripped.match(/^\s*\}/)
|
|
130
|
+
if (emptyBlockMatch) {
|
|
131
|
+
// Empty block: replace {} with { newEntries }
|
|
132
|
+
const emptyEndPos = mapStrippedPosition(raw, suffix, suffixStripped.indexOf("}") + 1)
|
|
133
|
+
modified = prefix + "\n " + newEntries + "\n " + raw.slice(insertPos + emptyEndPos)
|
|
134
|
+
} else {
|
|
135
|
+
// Non-empty block: prepend entries after opening brace
|
|
136
|
+
// We prepend (not append) so user's existing entries stay at the bottom
|
|
137
|
+
modified = prefix + "\n " + newEntries + ",\n " + suffix.trimStart()
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
// No "tools" block — create one before the closing root brace
|
|
141
|
+
const toolsBlock = `"tools": {\n ${newEntries}\n }`
|
|
142
|
+
|
|
143
|
+
// Use stripped version to find closing brace (comments can contain } chars)
|
|
144
|
+
const strippedForBrace = stripJsonComments(raw)
|
|
145
|
+
const closingBrace = findClosingRootBrace(strippedForBrace)
|
|
146
|
+
|
|
147
|
+
if (closingBrace >= 0) {
|
|
148
|
+
// Found closing brace — insert tools block before it
|
|
149
|
+
// Use lastIndexOf on original since stripped positions don't map cleanly here
|
|
150
|
+
const lastBrace = raw.lastIndexOf("}")
|
|
151
|
+
const beforeRaw = raw.slice(0, lastBrace).trimEnd()
|
|
152
|
+
const afterRaw = raw.slice(lastBrace)
|
|
153
|
+
// Add comma only if root object has other keys
|
|
154
|
+
const prefix = beforeRaw === "{" ? "" : ","
|
|
155
|
+
modified = beforeRaw + prefix + "\n " + toolsBlock + "\n" + afterRaw
|
|
156
|
+
} else {
|
|
157
|
+
// No closing brace found (malformed JSON) — append tools block
|
|
158
|
+
modified = raw.trimEnd() + ",\n " + toolsBlock + "\n}"
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Safety check: validate the modified content parses as valid JSON
|
|
163
|
+
// Catches bugs in string manipulation before corrupting the config file
|
|
164
|
+
try {
|
|
165
|
+
JSON.parse(stripJsonComments(modified))
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error("Generated invalid JSONC when disabling MCP tools")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await safeWriteFile(configPath, modified)
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Ensures auto-created subagents exist for all unassigned MCP servers.
|
|
176
|
+
*
|
|
177
|
+
* An MCP server is "unassigned" when no existing subagent covers it AND
|
|
178
|
+
* it hasn't been previously auto-created and removed by the user (tracked
|
|
179
|
+
* via the lock file).
|
|
180
|
+
*
|
|
181
|
+
* Creates one subagent per MCP server with:
|
|
182
|
+
* - name = MCP server name
|
|
183
|
+
* - description = server description, or "<name> operations" fallback
|
|
184
|
+
* - mode = "subagent"
|
|
185
|
+
* - tools = { "name_*": true }
|
|
186
|
+
*
|
|
187
|
+
* @returns number of subagents created
|
|
188
|
+
*/
|
|
189
|
+
export async function ensureSubagentsCreated(
|
|
190
|
+
directory: string,
|
|
191
|
+
mcpServers: McpServer[],
|
|
192
|
+
existingSubagents: Subagent[]
|
|
193
|
+
): Promise<number> {
|
|
194
|
+
if (mcpServers.length === 0) return 0
|
|
195
|
+
|
|
196
|
+
// Find which MCPs are already covered by existing subagents
|
|
197
|
+
const covered = new Set<string>()
|
|
198
|
+
for (const sa of existingSubagents) {
|
|
199
|
+
for (const m of sa.mcpServers) {
|
|
200
|
+
covered.add(m)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Read lock file — MCPs in autoCreated were previously created
|
|
205
|
+
// If user deleted them, they stay in the lock file and we respect that
|
|
206
|
+
const lock = await readLock(directory)
|
|
207
|
+
const previouslyCreated = new Set(
|
|
208
|
+
lock ? Object.keys(lock.autoCreated) : []
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// MCPs that need subagents: not covered AND not previously declined
|
|
212
|
+
const toCreate = mcpServers.filter(
|
|
213
|
+
(m) => !covered.has(m.name) && !previouslyCreated.has(m.name)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if (toCreate.length === 0) return 0
|
|
217
|
+
|
|
218
|
+
const resolved = await findProjectConfigPath(directory)
|
|
219
|
+
if (!resolved) return 0
|
|
220
|
+
|
|
221
|
+
let raw: string
|
|
222
|
+
try {
|
|
223
|
+
raw = await readFile(resolved.path, "utf-8")
|
|
224
|
+
// Size limit: reject files > 1MB
|
|
225
|
+
if (raw.length > MAX_CONFIG_SIZE) return 0
|
|
226
|
+
} catch {
|
|
227
|
+
raw = "{}"
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Strip BOM (Windows editors may prepend it)
|
|
231
|
+
raw = stripBOM(raw)
|
|
232
|
+
|
|
233
|
+
// Build subagent entries as JSON text
|
|
234
|
+
const entries = toCreate.map((mcp) => {
|
|
235
|
+
const desc = mcp.description
|
|
236
|
+
? jsonEscape(mcp.description)
|
|
237
|
+
: `${mcp.name} operations`
|
|
238
|
+
return `"${mcp.name}": {\n "description": "${desc}",\n "mode": "subagent",\n "tools": {\n "${mcp.name}_*": true\n }\n }`
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const entriesText = entries.join(",\n ")
|
|
242
|
+
|
|
243
|
+
const stripped = stripJsonComments(raw)
|
|
244
|
+
const agentMatch = stripped.match(/"agent"\s*:\s*\{/)
|
|
245
|
+
|
|
246
|
+
let modified: string
|
|
247
|
+
|
|
248
|
+
if (agentMatch) {
|
|
249
|
+
const insertPos = mapStrippedPosition(
|
|
250
|
+
raw, stripped,
|
|
251
|
+
agentMatch.index! + agentMatch[0].length
|
|
252
|
+
)
|
|
253
|
+
const prefix = raw.slice(0, insertPos)
|
|
254
|
+
const suffix = raw.slice(insertPos)
|
|
255
|
+
|
|
256
|
+
const suffixStripped = stripJsonComments(suffix)
|
|
257
|
+
const emptyBlockMatch = suffixStripped.match(/^\s*\}/)
|
|
258
|
+
if (emptyBlockMatch) {
|
|
259
|
+
const emptyEndPos = mapStrippedPosition(
|
|
260
|
+
raw, suffix,
|
|
261
|
+
suffixStripped.indexOf("}") + 1
|
|
262
|
+
)
|
|
263
|
+
modified = prefix + "\n " + entriesText + "\n " +
|
|
264
|
+
raw.slice(insertPos + emptyEndPos)
|
|
265
|
+
} else {
|
|
266
|
+
modified =
|
|
267
|
+
prefix + "\n " + entriesText + ",\n " + suffix.trimStart()
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
const agentBlock =
|
|
271
|
+
`"agent": {\n ${entriesText}\n }`
|
|
272
|
+
|
|
273
|
+
const strippedForBrace = stripJsonComments(raw)
|
|
274
|
+
const closingBrace = findClosingRootBrace(strippedForBrace)
|
|
275
|
+
|
|
276
|
+
if (closingBrace >= 0) {
|
|
277
|
+
const lastBrace = raw.lastIndexOf("}")
|
|
278
|
+
const beforeRaw = raw.slice(0, lastBrace).trimEnd()
|
|
279
|
+
const afterRaw = raw.slice(lastBrace)
|
|
280
|
+
const prefix = beforeRaw === "{" ? "" : ","
|
|
281
|
+
modified = beforeRaw + prefix + "\n " + agentBlock + "\n" + afterRaw
|
|
282
|
+
} else {
|
|
283
|
+
modified = raw.trimEnd() + ",\n " + agentBlock + "\n}"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Safety check
|
|
288
|
+
try {
|
|
289
|
+
JSON.parse(stripJsonComments(modified))
|
|
290
|
+
} catch {
|
|
291
|
+
throw new Error("Generated invalid JSONC when creating subagent entries")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await safeWriteFile(resolved.path, modified)
|
|
295
|
+
|
|
296
|
+
// Update lock file
|
|
297
|
+
const newAutoCreated: Record<string, string> = {
|
|
298
|
+
...(lock?.autoCreated ?? {}),
|
|
299
|
+
}
|
|
300
|
+
for (const m of toCreate) {
|
|
301
|
+
newAutoCreated[m.name] = m.name
|
|
302
|
+
}
|
|
303
|
+
await writeLock(directory, {
|
|
304
|
+
version: 1,
|
|
305
|
+
autoCreated: newAutoCreated,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
return toCreate.length
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Finds the project-level opencode config file path.
|
|
313
|
+
*
|
|
314
|
+
* Search order (first match wins):
|
|
315
|
+
* 1. .opencode/opencode.json
|
|
316
|
+
* 2. .opencode/opencode.jsonc
|
|
317
|
+
* 3. opencode.jsonc (project root)
|
|
318
|
+
* 4. opencode.json (project root)
|
|
319
|
+
*
|
|
320
|
+
* If no project config exists, checks for a global config.
|
|
321
|
+
* If global exists, returns a new project path (.opencode/opencode.jsonc)
|
|
322
|
+
* with exists: false — caller should create it.
|
|
323
|
+
*
|
|
324
|
+
* Returns null if neither project nor global config exists.
|
|
325
|
+
*/
|
|
326
|
+
async function findProjectConfigPath(
|
|
327
|
+
directory: string
|
|
328
|
+
): Promise<{ path: string; exists: boolean } | null> {
|
|
329
|
+
const paths = [
|
|
330
|
+
join(directory, ".opencode", "opencode.json"),
|
|
331
|
+
join(directory, ".opencode", "opencode.jsonc"),
|
|
332
|
+
join(directory, "opencode.jsonc"),
|
|
333
|
+
join(directory, "opencode.json"),
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
for (const path of paths) {
|
|
337
|
+
if (!validatePath(path)) continue
|
|
338
|
+
try {
|
|
339
|
+
await readFile(path, "utf-8")
|
|
340
|
+
return { path, exists: true }
|
|
341
|
+
} catch {
|
|
342
|
+
// File not found — try next path
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// No project config — check if global config exists
|
|
347
|
+
// If so, we'll create a project-level override with just the tools section
|
|
348
|
+
const globalPaths = [
|
|
349
|
+
join(homedir(), ".config", "opencode", "opencode.jsonc"),
|
|
350
|
+
join(homedir(), ".config", "opencode", "opencode.json"),
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
for (const path of globalPaths) {
|
|
354
|
+
if (!validatePath(path)) continue
|
|
355
|
+
try {
|
|
356
|
+
await readFile(path, "utf-8")
|
|
357
|
+
// Global has config — create project-level tools-only override
|
|
358
|
+
const newPath = join(directory, ".opencode", "opencode.jsonc")
|
|
359
|
+
return { path: newPath, exists: false }
|
|
360
|
+
} catch {
|
|
361
|
+
// Global not found either — try next path
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return null
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Maps a character position from a stripped (comment-free) string back to
|
|
370
|
+
* the original string with comments.
|
|
371
|
+
*
|
|
372
|
+
* Used when we find a match position in the stripped version (for regex
|
|
373
|
+
* matching) but need to insert/modify text in the original.
|
|
374
|
+
*
|
|
375
|
+
* Works by walking both strings in parallel — when characters match,
|
|
376
|
+
* advance the stripped index. Always advance the original index.
|
|
377
|
+
* When stripped index reaches the target position, original index is
|
|
378
|
+
* the corresponding position in the original string.
|
|
379
|
+
*
|
|
380
|
+
* O(n) where n is the original string length.
|
|
381
|
+
*/
|
|
382
|
+
function mapStrippedPosition(original: string, stripped: string, strippedPos: number): number {
|
|
383
|
+
let origIdx = 0
|
|
384
|
+
let strippedIdx = 0
|
|
385
|
+
while (strippedIdx < strippedPos && origIdx < original.length) {
|
|
386
|
+
if (original[origIdx] === stripped[strippedIdx]) {
|
|
387
|
+
strippedIdx++
|
|
388
|
+
}
|
|
389
|
+
origIdx++
|
|
390
|
+
}
|
|
391
|
+
return origIdx
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Strips JSONC comments from a raw JSON string.
|
|
396
|
+
*
|
|
397
|
+
* Handles:
|
|
398
|
+
* - Block comments: /* ... * /
|
|
399
|
+
* - Line comments: // ... (negative lookbehind (?<!:) avoids matching :// in URLs)
|
|
400
|
+
*
|
|
401
|
+
* Does NOT handle trailing commas — that's handled separately in stripJsonc().
|
|
402
|
+
* This version is simpler because we only need it for regex matching, not parsing.
|
|
403
|
+
*/
|
|
404
|
+
function stripJsonComments(raw: string): string {
|
|
405
|
+
let result = raw.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
406
|
+
result = result.replace(/(?<!:)\/\/.*$/gm, "")
|
|
407
|
+
return result
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function jsonEscape(s: string): string {
|
|
411
|
+
return s
|
|
412
|
+
.replace(/\\/g, "\\\\")
|
|
413
|
+
.replace(/"/g, '\\"')
|
|
414
|
+
.replace(/\n/g, "\\n")
|
|
415
|
+
.replace(/\r/g, "\\r")
|
|
416
|
+
.replace(/\t/g, "\\t")
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Finds the position of the closing brace of the root JSON object.
|
|
421
|
+
*
|
|
422
|
+
* Scans backwards from the end of the string, tracking:
|
|
423
|
+
* - Brace depth: } increments, { decrements
|
|
424
|
+
* - String state: ignores braces inside quoted strings
|
|
425
|
+
* - Escape sequences: counts consecutive backslashes before quotes
|
|
426
|
+
* to correctly handle \\" (escaped backslash + quote that opens string)
|
|
427
|
+
*
|
|
428
|
+
* Returns the index of the root closing brace, or -1 if not found.
|
|
429
|
+
*
|
|
430
|
+
* IMPORTANT: Input should be comment-stripped. Comments can contain
|
|
431
|
+
* braces that would throw off the depth counter.
|
|
432
|
+
*/
|
|
433
|
+
function findClosingRootBrace(raw: string): number {
|
|
434
|
+
let depth = 0
|
|
435
|
+
let inString = false
|
|
436
|
+
|
|
437
|
+
for (let i = raw.length - 1; i >= 0; i--) {
|
|
438
|
+
const ch = raw[i]
|
|
439
|
+
|
|
440
|
+
if (inString) {
|
|
441
|
+
if (ch === '"') {
|
|
442
|
+
// Count consecutive backslashes before the quote
|
|
443
|
+
// Odd count = quote is escaped (\"), even count = real string end
|
|
444
|
+
let backslashCount = 0
|
|
445
|
+
let j = i - 1
|
|
446
|
+
while (j >= 0 && raw[j] === '\\') {
|
|
447
|
+
backslashCount++
|
|
448
|
+
j--
|
|
449
|
+
}
|
|
450
|
+
if (backslashCount % 2 === 0) inString = false
|
|
451
|
+
}
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (ch === '"') {
|
|
456
|
+
inString = true
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (ch === "}") depth++
|
|
461
|
+
if (ch === "{") depth--
|
|
462
|
+
|
|
463
|
+
// depth === 1 means we just closed the root object
|
|
464
|
+
if (depth === 1 && ch === "}") return i
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return -1
|
|
468
|
+
}
|