@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.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.
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Byte-preserving editors for the config formats MCP clients actually ship.
3
+ *
4
+ * Every client file is shared property: it holds the user's other settings,
5
+ * comments, key order, and formatting. A full parse/serialize round trip
6
+ * destroys all of that (and `.claude.json` is a multi-megabyte mixed file),
7
+ * so each writer here computes text splices over the original string and
8
+ * touches only the bytes belonging to the container it owns.
9
+ *
10
+ * Formats:
11
+ * - JSON / JSONC `{ "mcpServers": { ... } }` (strict, comments tolerated)
12
+ * - TOML `[mcp_servers.<id>]` (Codex CLI)
13
+ * - YAML `mcpServers:` list of mappings (Continue) — read only
14
+ */
15
+
16
+ const TRIVIA = new Set([' ', '\t', '\r', '\n']);
17
+
18
+ /** Skip whitespace, `//` line comments, and block comments. */
19
+ function skipTrivia(text, pos) {
20
+ let i = pos;
21
+ for (;;) {
22
+ while (i < text.length && TRIVIA.has(text[i])) i += 1;
23
+ if (text[i] === '/' && text[i + 1] === '/') {
24
+ while (i < text.length && text[i] !== '\n') i += 1;
25
+ continue;
26
+ }
27
+ if (text[i] === '/' && text[i + 1] === '*') {
28
+ const end = text.indexOf('*/', i + 2);
29
+ i = end < 0 ? text.length : end + 2;
30
+ continue;
31
+ }
32
+ return i;
33
+ }
34
+ }
35
+
36
+ function scanString(text, pos) {
37
+ let i = pos + 1;
38
+ while (i < text.length) {
39
+ const ch = text[i];
40
+ if (ch === '\\') { i += 2; continue; }
41
+ if (ch === '"') return i + 1;
42
+ i += 1;
43
+ }
44
+ throw new Error(`unterminated string at ${pos}`);
45
+ }
46
+
47
+ /**
48
+ * Scan one JSON value into a node tree carrying byte offsets for every key and
49
+ * value. Offsets are what make surgical splicing possible.
50
+ */
51
+ function scanValue(text, pos) {
52
+ const start = pos;
53
+ const ch = text[pos];
54
+ if (ch === '{') {
55
+ const pairs = [];
56
+ let i = skipTrivia(text, pos + 1);
57
+ if (text[i] === '}') return { type: 'object', start, end: i + 1, pairs };
58
+ for (;;) {
59
+ i = skipTrivia(text, i);
60
+ if (text[i] !== '"') throw new Error(`expected key at ${i}`);
61
+ const keyStart = i;
62
+ const keyEnd = scanString(text, i);
63
+ const key = JSON.parse(text.slice(keyStart, keyEnd));
64
+ i = skipTrivia(text, keyEnd);
65
+ if (text[i] !== ':') throw new Error(`expected ':' at ${i}`);
66
+ i = skipTrivia(text, i + 1);
67
+ const value = scanValue(text, i);
68
+ i = skipTrivia(text, value.end);
69
+ const pair = { key, keyStart, keyEnd, pairStart: keyStart, value };
70
+ if (text[i] === ',') {
71
+ pair.commaEnd = i + 1;
72
+ pairs.push(pair);
73
+ i = skipTrivia(text, i + 1);
74
+ if (text[i] === '}') return { type: 'object', start, end: i + 1, pairs };
75
+ continue;
76
+ }
77
+ pairs.push(pair);
78
+ if (text[i] === '}') return { type: 'object', start, end: i + 1, pairs };
79
+ throw new Error(`expected ',' or '}' at ${i}`);
80
+ }
81
+ }
82
+ if (ch === '[') {
83
+ const items = [];
84
+ let i = skipTrivia(text, pos + 1);
85
+ if (text[i] === ']') return { type: 'array', start, end: i + 1, items };
86
+ for (;;) {
87
+ i = skipTrivia(text, i);
88
+ if (text[i] === ']') return { type: 'array', start, end: i + 1, items };
89
+ if (text[i] === undefined) throw new Error('unterminated array');
90
+ const item = scanValue(text, i);
91
+ items.push(item);
92
+ i = skipTrivia(text, item.end);
93
+ if (text[i] === ',') { i = skipTrivia(text, i + 1); continue; }
94
+ if (text[i] === ']') return { type: 'array', start, end: i + 1, items };
95
+ // tolerate a missing comma between items: the next iteration re-reads
96
+ continue;
97
+ }
98
+ }
99
+ if (ch === '"') return { type: 'string', start, end: scanString(text, pos) };
100
+ let i = pos;
101
+ while (i < text.length && !',}] \t\r\n'.includes(text[i])) i += 1;
102
+ if (i === pos) throw new Error(`unexpected '${ch}' at ${pos}`);
103
+ return { type: 'scalar', start, end: i };
104
+ }
105
+
106
+ function keyStartOf(text, keyEnd) {
107
+ // Walk back from end of the key string to its opening quote.
108
+ let i = keyEnd - 1;
109
+ while (i > 0) {
110
+ if (text[i] === '"' && text[i - 1] !== '\\') return i;
111
+ i -= 1;
112
+ }
113
+ return 0;
114
+ }
115
+
116
+ /** Parse JSON/JSONC text leniently; returns `{ value, tree, text }`. */
117
+ export function parseJsonc(text) {
118
+ const source = typeof text === 'string' ? text : '';
119
+ const start = skipTrivia(source, 0);
120
+ if (start >= source.length) return { value: {}, tree: null, text: source };
121
+ const tree = scanValue(source, start);
122
+ const value = strictJson(source.slice(tree.start, tree.end));
123
+ return { value, tree, text: source };
124
+ }
125
+
126
+ /** JSON.parse that tolerates comments and trailing commas. */
127
+ export function strictJson(chunk) {
128
+ const cleaned = stripComments(chunk).replace(/,(\s*[}\]])/g, '$1');
129
+ return JSON.parse(cleaned);
130
+ }
131
+
132
+ function stripComments(chunk) {
133
+ let out = '';
134
+ let i = 0;
135
+ while (i < chunk.length) {
136
+ const ch = chunk[i];
137
+ if (ch === '"') {
138
+ const end = scanString(chunk, i);
139
+ out += chunk.slice(i, end);
140
+ i = end;
141
+ continue;
142
+ }
143
+ if (ch === '/' && chunk[i + 1] === '/') {
144
+ while (i < chunk.length && chunk[i] !== '\n') i += 1;
145
+ continue;
146
+ }
147
+ if (ch === '/' && chunk[i + 1] === '*') {
148
+ const end = chunk.indexOf('*/', i + 2);
149
+ i = end < 0 ? chunk.length : end + 2;
150
+ continue;
151
+ }
152
+ out += ch;
153
+ i += 1;
154
+ }
155
+ return out;
156
+ }
157
+
158
+ function findNode(tree, path) {
159
+ let node = tree;
160
+ for (const key of path) {
161
+ if (!node || node.type !== 'object') return null;
162
+ const pair = node.pairs.find((item) => item.key === key);
163
+ if (!pair) return null;
164
+ node = pair.value;
165
+ }
166
+ return node;
167
+ }
168
+
169
+ function detectIndentUnit(text) {
170
+ const match = /\n([ \t]+)\S/.exec(text);
171
+ if (!match) return ' ';
172
+ return match[1].includes('\t') ? match[1] : match[1].length >= 4 ? match[1].slice(0, 4) : match[1] || ' ';
173
+ }
174
+
175
+ function indentOfLine(text, offset) {
176
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
177
+ const line = text.slice(lineStart, offset);
178
+ const match = /^[ \t]*/.exec(line);
179
+ return match ? match[0] : '';
180
+ }
181
+
182
+ function applyEdits(text, edits) {
183
+ const sorted = [...edits].sort((a, b) => b.start - a.start);
184
+ let out = text;
185
+ for (const edit of sorted) {
186
+ // Guard against overlapping splices: skip any edit fully inside a later one.
187
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function renderJson(value, indentUnit, depth) {
193
+ const pad = indentUnit.repeat(depth);
194
+ const childPad = indentUnit.repeat(depth + 1);
195
+ const js = JSON.stringify(value, null, indentUnit);
196
+ if (js === undefined) return 'null';
197
+ return js.replace(/\n/g, `\n${pad}`).split('\n').slice(0, 1).join('') === ''
198
+ ? js
199
+ : js;
200
+ }
201
+
202
+ function insertionFor(text, containerNode, key, value) {
203
+ const unit = detectIndentUnit(text);
204
+ const pairs = containerNode.pairs;
205
+ const serialized = JSON.stringify(value, null, unit);
206
+ if (pairs.length === 0) {
207
+ const pad = `${indentOfLine(text, containerNode.start)}${unit}`;
208
+ const body = `${serialized}`.replace(/\n/g, `\n${pad}`);
209
+ return { start: containerNode.start + 1, end: containerNode.start + 1, text: `\n${pad}"${key}": ${body}\n${indentOfLine(text, containerNode.start)}` };
210
+ }
211
+ const last = pairs[pairs.length - 1];
212
+ const pad = `${indentOfLine(text, last.pairStart)}`;
213
+ const body = `${serialized}`.replace(/\n/g, `\n${pad}`);
214
+ return { start: last.value.end, end: last.value.end, text: `,\n${pad}"${key}": ${body}` };
215
+ }
216
+
217
+ function removalFor(text, containerNode, pair) {
218
+ const pairs = containerNode.pairs;
219
+ const index = pairs.indexOf(pair);
220
+ if (index > 0) {
221
+ const previous = pairs[index - 1];
222
+ return { start: previous.value.end, end: pair.value.end, text: '' };
223
+ }
224
+ if (pairs.length > 1) {
225
+ const next = pairs[1];
226
+ return { start: pair.pairStart, end: next.pairStart, text: '' };
227
+ }
228
+ return { start: pair.pairStart, end: pair.value.end, text: '' };
229
+ }
230
+
231
+ /**
232
+ * Read the named container object (e.g. `mcpServers`) as a plain value plus the
233
+ * node needed to splice it. Missing container yields an empty object.
234
+ */
235
+ export function readContainer(text, containerPath) {
236
+ const source = typeof text === 'string' ? text : '';
237
+ let parsed;
238
+ try {
239
+ parsed = parseJsonc(source);
240
+ } catch (error) {
241
+ return { ok: false, error: `parse error: ${error.message}`, value: {} };
242
+ }
243
+ const node = parsed.tree ? findNode(parsed.tree, [containerPath]) : null;
244
+ const value = node && node.type === 'object' ? strictJson(source.slice(node.start, node.end)) : {};
245
+ return { ok: true, value, node, tree: parsed.tree };
246
+ }
247
+
248
+ /**
249
+ * Set one entry inside a container object, creating the container when absent.
250
+ * Returns `{ ok, text }`; unrelated bytes are preserved.
251
+ */
252
+ export function writeContainerEntry(text, containerPath, key, value) {
253
+ const source = typeof text === 'string' ? text : '';
254
+ let parsed;
255
+ try {
256
+ parsed = parseJsonc(source);
257
+ } catch (error) {
258
+ return { ok: false, error: `parse error: ${error.message}` };
259
+ }
260
+ const unit = detectIndentUnit(source);
261
+ if (!parsed.tree) {
262
+ const fresh = { [containerPath]: { [key]: value } };
263
+ return { ok: true, text: `${JSON.stringify(fresh, null, unit)}\n` };
264
+ }
265
+ if (parsed.tree.type !== 'object') {
266
+ return { ok: false, error: 'root value is not an object' };
267
+ }
268
+ const container = findNode(parsed.tree, [containerPath]);
269
+ if (!container) {
270
+ const edit = insertionFor(source, parsed.tree, containerPath, { [key]: value });
271
+ return { ok: true, text: applyEdits(source, [edit]) };
272
+ }
273
+ if (container.type !== 'object') {
274
+ return { ok: false, error: `${containerPath} is not an object` };
275
+ }
276
+ const existing = container.pairs.find((pair) => pair.key === key);
277
+ if (existing) {
278
+ const edit = { start: existing.value.start, end: existing.value.end, text: JSON.stringify(value, null, unit).replace(/\n/g, `\n${indentOfLine(source, existing.pairStart)}`) };
279
+ return { ok: true, text: applyEdits(source, [edit]) };
280
+ }
281
+ const edit = insertionFor(source, container, key, value);
282
+ return { ok: true, text: applyEdits(source, [edit]) };
283
+ }
284
+
285
+ /** Remove one container entry; a no-op when the key is absent. */
286
+ export function removeContainerEntry(text, containerPath, key) {
287
+ const source = typeof text === 'string' ? text : '';
288
+ let parsed;
289
+ try {
290
+ parsed = parseJsonc(source);
291
+ } catch (error) {
292
+ return { ok: false, error: `parse error: ${error.message}` };
293
+ }
294
+ if (!parsed.tree) return { ok: true, text: source, changed: false };
295
+ const container = findNode(parsed.tree, [containerPath]);
296
+ if (!container || container.type !== 'object') return { ok: true, text: source, changed: false };
297
+ const pair = container.pairs.find((item) => item.key === key);
298
+ if (!pair) return { ok: true, text: source, changed: false };
299
+ return { ok: true, text: applyEdits(source, [removalFor(source, container, pair)]), changed: true };
300
+ }
301
+
302
+ /** Rename one container entry in place (used for collision suffixing). */
303
+ export function renameContainerEntry(text, containerPath, from, to) {
304
+ const source = typeof text === 'string' ? text : '';
305
+ let parsed;
306
+ try {
307
+ parsed = parseJsonc(source);
308
+ } catch (error) {
309
+ return { ok: false, error: `parse error: ${error.message}` };
310
+ }
311
+ const container = parsed.tree ? findNode(parsed.tree, [containerPath]) : null;
312
+ if (!container || container.type !== 'object') return { ok: false, error: 'container not found' };
313
+ const pair = container.pairs.find((item) => item.key === from);
314
+ if (!pair) return { ok: false, error: 'entry not found' };
315
+ return { ok: true, text: applyEdits(source, [{ start: pair.keyStart, end: pair.keyStart + JSON.stringify(from).length, text: JSON.stringify(to) }]) };
316
+ }
317
+
318
+ /* ------------------------------------------------------------------ TOML --- */
319
+
320
+ const TOML_TABLE = /^\s*\[\[?([^\]]+)\]\]?\s*(?:#.*)?$/;
321
+ const TOML_KEYVAL = /^\s*([A-Za-z0-9_."-]+)\s*=\s*(.*)$/;
322
+
323
+ function tomlSplitKeyPath(raw) {
324
+ const parts = [];
325
+ let current = '';
326
+ let quoted = false;
327
+ for (let i = 0; i < raw.length; i += 1) {
328
+ const ch = raw[i];
329
+ if (quoted) {
330
+ if (ch === '\\') { current += ch + raw[i + 1]; i += 1; continue; }
331
+ if (ch === '"') { quoted = false; continue; }
332
+ current += ch;
333
+ continue;
334
+ }
335
+ if (ch === '"') { quoted = true; continue; }
336
+ if (ch === '.') { parts.push(current.trim()); current = ''; continue; }
337
+ current += ch;
338
+ }
339
+ parts.push(current.trim());
340
+ return parts.map((part) => part.replace(/^['"]|['"]$/g, ''));
341
+ }
342
+
343
+ function tomlParseValue(raw) {
344
+ const text = raw.trim().replace(/\s+#.*$/, '');
345
+ if (text.startsWith('"') || text.startsWith("'")) {
346
+ try {
347
+ return text.startsWith('"') ? JSON.parse(text.slice(0, scanString(text, 0))) : text.slice(1, text.lastIndexOf("'"));
348
+ } catch {
349
+ return text.slice(1, -1);
350
+ }
351
+ }
352
+ if (text.startsWith('[')) {
353
+ try {
354
+ const inner = text.slice(1, text.lastIndexOf(']'));
355
+ const items = [];
356
+ let i = 0;
357
+ while (i < inner.length) {
358
+ while (i < inner.length && ' ,\r\n\t'.includes(inner[i])) i += 1;
359
+ if (i >= inner.length) break;
360
+ if (inner[i] === '"' || inner[i] === "'") {
361
+ const end = inner[i] === '"' ? scanString(inner, i) : inner.indexOf("'", i + 1) + 1;
362
+ const chunk = inner.slice(i, end);
363
+ items.push(chunk.startsWith('"') ? JSON.parse(chunk) : chunk.slice(1, -1));
364
+ i = end;
365
+ continue;
366
+ }
367
+ const comma = inner.indexOf(',', i);
368
+ const stop = comma < 0 ? inner.length : comma;
369
+ items.push(inner.slice(i, stop).trim());
370
+ i = stop;
371
+ }
372
+ return items;
373
+ } catch {
374
+ return [];
375
+ }
376
+ }
377
+ if (text.startsWith('{')) {
378
+ const table = {};
379
+ const inner = text.slice(1, text.lastIndexOf('}'));
380
+ for (const piece of inner.split(',')) {
381
+ const match = TOML_KEYVAL.exec(piece);
382
+ if (!match) continue;
383
+ table[match[1].replace(/^['"]|['"]$/g, '')] = tomlParseValue(match[2]);
384
+ }
385
+ return table;
386
+ }
387
+ if (text === 'true') return true;
388
+ if (text === 'false') return false;
389
+ const number = Number(text);
390
+ return Number.isFinite(number) && text !== '' ? number : text;
391
+ }
392
+
393
+ /** Split TOML into leading lines plus table blocks. */
394
+ export function tomlBlocks(text) {
395
+ const lines = (typeof text === 'string' ? text : '').split('\n');
396
+ const blocks = [];
397
+ let current = { path: [], headerLine: -1, start: 0, lines: [] };
398
+ for (let i = 0; i < lines.length; i += 1) {
399
+ const match = TOML_TABLE.exec(lines[i]);
400
+ if (match) {
401
+ current.end = i;
402
+ blocks.push(current);
403
+ current = { path: tomlSplitKeyPath(match[1]), headerLine: i, start: i, lines: [lines[i]] };
404
+ continue;
405
+ }
406
+ current.lines.push(lines[i]);
407
+ }
408
+ current.end = lines.length;
409
+ blocks.push(current);
410
+ return blocks;
411
+ }
412
+
413
+ /** Read every `[mcp_servers.<id>]` table into a plain object. */
414
+ export function readTomlServers(text) {
415
+ const servers = {};
416
+ for (const block of tomlBlocks(text)) {
417
+ if (block.path[0] !== 'mcp_servers' || block.path.length < 2) continue;
418
+ const name = block.path[1];
419
+ const entry = servers[name] ?? (servers[name] = {});
420
+ if (block.path.length === 2) {
421
+ for (const line of block.lines) {
422
+ const match = TOML_KEYVAL.exec(line);
423
+ if (!match) continue;
424
+ entry[match[1].replace(/^['"]|['"]$/g, '')] = tomlParseValue(match[2]);
425
+ }
426
+ continue;
427
+ }
428
+ // Nested sub-table, e.g. `[mcp_servers.x.env]` or `[mcp_servers.x.tools.foo]`.
429
+ let target = entry;
430
+ for (const key of block.path.slice(2)) {
431
+ const next = target[key];
432
+ if (next && typeof next === 'object' && !Array.isArray(next)) target = next;
433
+ else target = target[key] = {};
434
+ }
435
+ for (const line of block.lines) {
436
+ const match = TOML_KEYVAL.exec(line);
437
+ if (!match) continue;
438
+ target[match[1].replace(/^['"]|['"]$/g, '')] = tomlParseValue(match[2]);
439
+ }
440
+ }
441
+ return servers;
442
+ }
443
+
444
+ function tomlQuote(value) {
445
+ return JSON.stringify(String(value));
446
+ }
447
+
448
+ function tomlKey(key) {
449
+ return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlQuote(key);
450
+ }
451
+
452
+ /** Render one server as TOML tables (base table plus nested tables). */
453
+ export function renderTomlServer(name, config) {
454
+ const lines = [`[mcp_servers.${tomlKey(name)}]`];
455
+ const nested = [];
456
+ for (const [key, value] of Object.entries(config)) {
457
+ if (value === undefined || value === null) continue;
458
+ if (typeof value === 'object' && !Array.isArray(value)) {
459
+ nested.push([key, value]);
460
+ continue;
461
+ }
462
+ if (Array.isArray(value)) lines.push(`${tomlKey(key)} = [${value.map(tomlQuote).join(', ')}]`);
463
+ else if (typeof value === 'number') lines.push(`${tomlKey(key)} = ${value}`);
464
+ else if (typeof value === 'boolean') lines.push(`${tomlKey(key)} = ${value}`);
465
+ else lines.push(`${tomlKey(key)} = ${tomlQuote(value)}`);
466
+ }
467
+ for (const [key, value] of nested) {
468
+ lines.push('', `[mcp_servers.${tomlKey(name)}.${tomlKey(key)}]`);
469
+ for (const [innerKey, innerValue] of Object.entries(value)) {
470
+ if (innerValue === undefined || innerValue === null) continue;
471
+ if (Array.isArray(innerValue)) lines.push(`${tomlKey(innerKey)} = [${innerValue.map(tomlQuote).join(', ')}]`);
472
+ else if (typeof innerValue === 'number') lines.push(`${tomlKey(innerKey)} = ${innerValue}`);
473
+ else if (typeof innerValue === 'boolean') lines.push(`${tomlKey(innerKey)} = ${innerValue}`);
474
+ else lines.push(`${tomlKey(innerKey)} = ${tomlQuote(innerValue)}`);
475
+ }
476
+ }
477
+ return `${lines.join('\n')}\n`;
478
+ }
479
+
480
+ /** Remove every `[mcp_servers.<name>]` table (and its nested tables). */
481
+ export function removeTomlServer(text, name) {
482
+ const source = typeof text === 'string' ? text : '';
483
+ const lines = source.split('\n');
484
+ const keep = [];
485
+ let skip = false;
486
+ for (let i = 0; i < lines.length; i += 1) {
487
+ const match = TOML_TABLE.exec(lines[i]);
488
+ if (match) {
489
+ const path = tomlSplitKeyPath(match[1]);
490
+ skip = path[0] === 'mcp_servers' && path[1] === name;
491
+ }
492
+ if (!skip) keep.push(lines[i]);
493
+ }
494
+ return keep.join('\n');
495
+ }
496
+
497
+ /** Replace one `[mcp_servers.<name>]` block, preserving every other byte. */
498
+ export function writeTomlServer(text, name, config) {
499
+ const source = typeof text === 'string' ? text : '';
500
+ const stripped = removeTomlServer(source, name).replace(/\n{3,}$/, '\n\n');
501
+ const block = renderTomlServer(name, config);
502
+ const base = stripped.trimEnd();
503
+ return base ? `${base}\n\n${block}` : block;
504
+ }
505
+
506
+ /* ------------------------------------------------------------------ YAML --- */
507
+
508
+ /**
509
+ * Extract `mcpServers` from a Continue-style YAML file. Indentation-based and
510
+ * deliberately narrow: enough to list servers and their scalar fields, not a
511
+ * general YAML parser.
512
+ */
513
+ export function readYamlServers(text) {
514
+ const lines = (typeof text === 'string' ? text : '').split('\n');
515
+ const servers = {};
516
+ let inBlock = false;
517
+ let blockIndent = -1;
518
+ let entryIndent = -1;
519
+ let current = null;
520
+ let lastKey = null;
521
+ for (const raw of lines) {
522
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
523
+ const indent = raw.length - raw.trimStart().length;
524
+ const trimmed = raw.trim();
525
+ if (!inBlock) {
526
+ if (/^mcpServers\s*:/.test(trimmed)) {
527
+ inBlock = true;
528
+ blockIndent = indent;
529
+ }
530
+ continue;
531
+ }
532
+ if (indent <= blockIndent) break;
533
+ const dash = /^-(?:\s+(.*))?$/.exec(trimmed);
534
+ if (dash) {
535
+ const body = (dash[1] ?? '').trim();
536
+ // A dash indented deeper than the entry belongs to the previous key —
537
+ // `args:` followed by `- -y` items. Treating these as entries would
538
+ // invent a server named `-y`.
539
+ if (current && indent > entryIndent) {
540
+ if (lastKey) {
541
+ const value = parseYamlScalar(body);
542
+ const existing = current.entry[lastKey];
543
+ if (Array.isArray(existing)) existing.push(value);
544
+ else current.entry[lastKey] = [value];
545
+ }
546
+ continue;
547
+ }
548
+ entryIndent = indent;
549
+ lastKey = null;
550
+ const nameMatch = /^name\s*:\s*(.*)$/.exec(body);
551
+ const name = nameMatch && nameMatch[1].trim() ? unquoteYaml(nameMatch[1]) : `server-${Object.keys(servers).length + 1}`;
552
+ current = { name, entry: {} };
553
+ servers[name] = current.entry;
554
+ continue;
555
+ }
556
+ if (!current) continue;
557
+ const pair = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(trimmed);
558
+ if (!pair) continue;
559
+ lastKey = pair[1];
560
+ current.entry[lastKey] = parseYamlScalar(pair[2]);
561
+ }
562
+ return servers;
563
+ }
564
+
565
+ function unquoteYaml(raw) {
566
+ const text = raw.trim();
567
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) return text.slice(1, -1);
568
+ return text.replace(/\s+#.*$/, '');
569
+ }
570
+
571
+ function parseYamlScalar(raw) {
572
+ const text = raw.trim();
573
+ if (text === '') return '';
574
+ if (text.startsWith('[') && text.endsWith(']')) {
575
+ return text.slice(1, -1).split(',').map((item) => unquoteYaml(item)).filter((item) => item !== '');
576
+ }
577
+ if (text === 'true') return true;
578
+ if (text === 'false') return false;
579
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
580
+ return unquoteYaml(text);
581
+ }