@gemus/mcp-proxy 0.1.9 → 0.1.10

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,110 @@
1
+ import { spawn as spawnChild } from 'node:child_process'
2
+
3
+ export const CANONICAL_MARKETPLACE_SOURCE = 'Gemus-AI/gemus-codex-plugin'
4
+ export const CODEX_MARKETPLACE = 'gemus'
5
+ export const CODEX_PLUGIN = 'gemus@gemus'
6
+
7
+ const OPERATIONS = Object.freeze(Object.assign(Object.create(null), {
8
+ listMarketplaces: Object.freeze(['plugin', 'marketplace', 'list', '--json']),
9
+ listPlugins: Object.freeze(['plugin', 'list', '--available', '--json']),
10
+ addMarketplace: Object.freeze(['plugin', 'marketplace', 'add', CANONICAL_MARKETPLACE_SOURCE]),
11
+ upgradeMarketplace: Object.freeze(['plugin', 'marketplace', 'upgrade', CODEX_MARKETPLACE]),
12
+ addPlugin: Object.freeze(['plugin', 'add', CODEX_PLUGIN, '--json']),
13
+ }))
14
+
15
+ const WINDOWS_COMMANDS = Object.freeze(Object.assign(Object.create(null), {
16
+ listMarketplaces: '"codex.cmd" plugin marketplace list --json',
17
+ listPlugins: '"codex.cmd" plugin list --available --json',
18
+ addMarketplace: '"codex.cmd" plugin marketplace add Gemus-AI/gemus-codex-plugin',
19
+ upgradeMarketplace: '"codex.cmd" plugin marketplace upgrade gemus',
20
+ addPlugin: '"codex.cmd" plugin add gemus@gemus --json',
21
+ }))
22
+
23
+ function own(object, key) { return Object.prototype.hasOwnProperty.call(object, key) }
24
+ function scrubGemusEnvironment(env) { return Object.fromEntries(Object.entries(env).filter(([name]) => !/^gemus_(?:key|url)$/i.test(name))) }
25
+
26
+ function spawnOperation(spawn, label, command, args, options) {
27
+ return new Promise((resolve, reject) => {
28
+ let child
29
+ try { child = spawn(command, args, options) } catch { reject(new Error(`Codex ${label} failed (exit 127).`)); return }
30
+ let stdout = ''
31
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk) })
32
+ child.once('error', () => reject(new Error(`Codex ${label} failed (exit 127).`)))
33
+ child.once('exit', (code) => code === 0 ? resolve(stdout) : reject(new Error(`Codex ${label} failed (exit ${Number.isInteger(code) ? code : 1}).`)))
34
+ })
35
+ }
36
+
37
+ export function createCodexRunner({ platform = process.platform, spawn = spawnChild, env = process.env, comspec = env.ComSpec ?? env.COMSPEC ?? 'cmd.exe' } = {}) {
38
+ if (platform !== 'win32' && platform !== 'darwin') throw new Error('Gemus Companion setup supports only Windows and macOS.')
39
+ const childEnv = scrubGemusEnvironment(env)
40
+ return async (operation) => {
41
+ if (!own(OPERATIONS, operation)) throw new Error('Unsupported Codex setup operation.')
42
+ if (platform === 'win32') return spawnOperation(spawn, operation, comspec, ['/d', '/s', '/c', WINDOWS_COMMANDS[operation]], { env: childEnv, stdio: ['ignore', 'pipe', 'ignore'], windowsVerbatimArguments: true })
43
+ return spawnOperation(spawn, operation, 'codex', OPERATIONS[operation], { env: childEnv, stdio: ['ignore', 'pipe', 'ignore'] })
44
+ }
45
+ }
46
+
47
+ function json(raw, label) { try { return JSON.parse(raw) } catch { throw new Error(`Codex ${label} returned invalid JSON.`) } }
48
+ function canonicalGitSource(sourceType, source) {
49
+ if (typeof sourceType !== 'string' || sourceType.toLowerCase() !== 'git' || typeof source !== 'string') return undefined
50
+ let candidate = source.trim()
51
+ if (/^git@github\.com:/i.test(candidate)) candidate = candidate.replace(/^git@github\.com:/i, '')
52
+ else if (/^https?:\/\/github\.com\//i.test(candidate)) candidate = candidate.replace(/^https?:\/\/github\.com\//i, '')
53
+ candidate = candidate.replace(/^\/+|\.git\/?$/gi, '').toLowerCase()
54
+ return candidate === 'gemus-ai/gemus-codex-plugin' ? CANONICAL_MARKETPLACE_SOURCE : undefined
55
+ }
56
+ function marketplaceRecord(value) {
57
+ if (!value || typeof value !== 'object' || typeof value.name !== 'string' || !value.marketplaceSource || typeof value.marketplaceSource !== 'object') throw new Error('Codex returned an unsupported marketplace record.')
58
+ const { sourceType, source } = value.marketplaceSource
59
+ if (typeof sourceType !== 'string' || typeof source !== 'string') throw new Error('Codex returned an unsupported marketplace record.')
60
+ return { ...value, canonicalSource: canonicalGitSource(sourceType, source) }
61
+ }
62
+ function pluginRecord(value) {
63
+ if (!value || typeof value !== 'object' || typeof value.pluginId !== 'string' || typeof value.name !== 'string' || typeof value.marketplaceName !== 'string' || typeof value.version !== 'string' || !value.version || typeof value.installed !== 'boolean' || !value.marketplaceSource || typeof value.marketplaceSource !== 'object') throw new Error('Codex returned an unsupported plugin record.')
64
+ const { sourceType, source } = value.marketplaceSource
65
+ if (typeof sourceType !== 'string' || typeof source !== 'string') throw new Error('Codex returned an unsupported plugin record.')
66
+ return { ...value, canonicalSource: canonicalGitSource(sourceType, source) }
67
+ }
68
+ function marketplaces(raw) {
69
+ const value = json(raw, 'marketplace list')
70
+ if (!value || typeof value !== 'object' || !Array.isArray(value.marketplaces)) throw new Error('Codex returned an unsupported marketplace JSON envelope.')
71
+ return value.marketplaces.map(marketplaceRecord)
72
+ }
73
+ function plugins(raw) {
74
+ const value = json(raw, 'plugin list')
75
+ if (!value || typeof value !== 'object' || !Array.isArray(value.installed) || !Array.isArray(value.available)) throw new Error('Codex returned an unsupported plugin JSON envelope.')
76
+ return { installed: value.installed.map(pluginRecord), available: value.available.map(pluginRecord) }
77
+ }
78
+ function advertisedPlugin(raw) {
79
+ const value = json(raw, 'plugin add')
80
+ const record = value.plugin ?? value
81
+ if (!record || typeof record !== 'object' || typeof record.pluginId !== 'string' || typeof record.name !== 'string' || typeof record.marketplaceName !== 'string' || typeof record.version !== 'string' || !record.version || typeof record.installedPath !== 'string' || typeof record.authPolicy !== 'string') throw new Error('Codex returned an unsupported plugin add result.')
82
+ if (record.pluginId !== CODEX_PLUGIN || record.marketplaceName.toLowerCase() !== CODEX_MARKETPLACE) throw new Error('Codex returned an untrusted plugin add result.')
83
+ return record
84
+ }
85
+ function gemusMarketplace(records) {
86
+ const matches = records.filter((record) => record.name.toLowerCase() === CODEX_MARKETPLACE)
87
+ if (matches.length > 1) throw new Error('Gemus marketplace is ambiguous; setup stopped before making changes.')
88
+ return matches[0]
89
+ }
90
+ function assertMarketplace(records) {
91
+ const record = gemusMarketplace(records)
92
+ if (!record) return undefined
93
+ if (!record.canonicalSource) throw new Error('The Gemus marketplace does not use the trusted source; setup stopped before making changes.')
94
+ return record
95
+ }
96
+ function verifiedPlugin(records, version) { return records.installed.find((record) => record.pluginId === CODEX_PLUGIN && record.installed && record.marketplaceName.toLowerCase() === CODEX_MARKETPLACE && record.canonicalSource && record.version === version) }
97
+
98
+ export async function reconcileCodex({ run = createCodexRunner() } = {}) {
99
+ const initialMarketplaces = marketplaces(await run('listMarketplaces'))
100
+ const initialPlugins = plugins(await run('listPlugins'))
101
+ const initialMarketplace = assertMarketplace(initialMarketplaces)
102
+ if (initialMarketplace) await run('upgradeMarketplace'); else await run('addMarketplace')
103
+ const reconciledMarketplace = assertMarketplace(marketplaces(await run('listMarketplaces')))
104
+ if (!reconciledMarketplace) throw new Error('Codex marketplace postcondition failed; plugin was not added.')
105
+ const advertised = advertisedPlugin(await run('addPlugin'))
106
+ const finalPlugins = plugins(await run('listPlugins'))
107
+ const installed = verifiedPlugin(finalPlugins, advertised.version)
108
+ if (!installed) throw new Error('Codex plugin postcondition failed.')
109
+ return { marketplace: reconciledMarketplace, plugin: installed, pluginInstalled: Boolean(verifiedPlugin(initialPlugins, advertised.version)) }
110
+ }
@@ -0,0 +1,530 @@
1
+ const LEGACY_ROOT = ['mcp_servers', 'gemus']
2
+ const COMPANION_ROOT = ['plugins', 'gemus@gemus', 'mcp_servers', 'gemus']
3
+
4
+ function splitLines(source) {
5
+ const lines = []
6
+ const expression = /.*?(?:\r\n|\n|\r|$)/g
7
+ let match
8
+ while ((match = expression.exec(source))) {
9
+ if (match[0] === '') break
10
+ const raw = match[0]
11
+ lines.push({ raw, text: raw.replace(/\r\n|\n|\r$/, '') })
12
+ }
13
+ return lines
14
+ }
15
+
16
+ function commentIndex(text) {
17
+ let quote = null
18
+ let escaped = false
19
+ for (let index = 0; index < text.length; index += 1) {
20
+ const character = text[index]
21
+ if (quote === '"' && escaped) {
22
+ escaped = false
23
+ continue
24
+ }
25
+ if (quote === '"' && character === '\\') {
26
+ escaped = true
27
+ continue
28
+ }
29
+ if (character === quote) {
30
+ quote = null
31
+ continue
32
+ }
33
+ if (!quote && (character === '"' || character === "'")) {
34
+ quote = character
35
+ continue
36
+ }
37
+ if (!quote && character === '#') return index
38
+ }
39
+ return -1
40
+ }
41
+
42
+ function withoutComment(text) {
43
+ const index = commentIndex(text)
44
+ return index === -1 ? text : text.slice(0, index)
45
+ }
46
+
47
+ function parseSegments(value) {
48
+ const segments = []
49
+ let index = 0
50
+ const skipWhitespace = () => {
51
+ while (/\s/.test(value[index] ?? '')) index += 1
52
+ }
53
+ while (index < value.length) {
54
+ skipWhitespace()
55
+ if (index >= value.length) break
56
+ let segment = ''
57
+ const quote = value[index]
58
+ if (quote === '"' || quote === "'") {
59
+ index += 1
60
+ const start = index
61
+ let escaped = false
62
+ while (index < value.length) {
63
+ const character = value[index]
64
+ if (quote === '"' && escaped) {
65
+ escaped = false
66
+ } else if (quote === '"' && character === '\\') {
67
+ escaped = true
68
+ } else if (character === quote) {
69
+ break
70
+ }
71
+ index += 1
72
+ }
73
+ if (value[index] !== quote) return undefined
74
+ const encoded = value.slice(start, index)
75
+ segment = quote === '"' ? decodeBasicKey(encoded) : encoded
76
+ if (segment === undefined) return undefined
77
+ index += 1
78
+ } else {
79
+ const match = /^[A-Za-z0-9_-]+/.exec(value.slice(index))
80
+ if (!match) return undefined
81
+ segment = match[0]
82
+ index += segment.length
83
+ }
84
+ segments.push(segment)
85
+ skipWhitespace()
86
+ if (index === value.length) break
87
+ if (value[index] !== '.') return undefined
88
+ index += 1
89
+ }
90
+ return segments.length ? segments : undefined
91
+ }
92
+
93
+ function decodeBasicKey(encoded) {
94
+ let decoded = ''
95
+ for (let index = 0; index < encoded.length; index += 1) {
96
+ const character = encoded[index]
97
+ if (character !== '\\') {
98
+ decoded += character
99
+ continue
100
+ }
101
+ const escape = encoded[++index]
102
+ const escapes = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\' }
103
+ if (escape in escapes) {
104
+ decoded += escapes[escape]
105
+ continue
106
+ }
107
+ const width = escape === 'u' ? 4 : escape === 'U' ? 8 : undefined
108
+ if (!width) return undefined
109
+ const hex = encoded.slice(index + 1, index + 1 + width)
110
+ if (!new RegExp(`^[0-9A-Fa-f]{${width}}$`).test(hex)) return undefined
111
+ const codePoint = Number.parseInt(hex, 16)
112
+ if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return undefined
113
+ decoded += String.fromCodePoint(codePoint)
114
+ index += width
115
+ }
116
+ return decoded
117
+ }
118
+
119
+ function parseHeader(text) {
120
+ const source = text.replace(/^\uFEFF/, '')
121
+ let index = 0
122
+ while (/\s/.test(source[index] ?? '')) index += 1
123
+ const array = source.startsWith('[[', index)
124
+ if (!array && source[index] !== '[') return undefined
125
+ index += array ? 2 : 1
126
+ const start = index
127
+ let quote = undefined
128
+ let escaped = false
129
+ while (index < source.length) {
130
+ const character = source[index]
131
+ if (quote === '"' && escaped) {
132
+ escaped = false
133
+ } else if (quote === '"' && character === '\\') {
134
+ escaped = true
135
+ } else if (character === quote) {
136
+ quote = undefined
137
+ } else if (!quote && (character === '"' || character === "'")) {
138
+ quote = character
139
+ } else if (!quote && character === ']') {
140
+ if (array && source[index + 1] !== ']') return undefined
141
+ const end = index
142
+ index += array ? 2 : 1
143
+ const trailing = source.slice(index)
144
+ if (!/^\s*(?:#.*)?$/.test(trailing)) return undefined
145
+ const segments = parseSegments(source.slice(start, end))
146
+ return segments ? { segments, array } : undefined
147
+ }
148
+ index += 1
149
+ }
150
+ return undefined
151
+ }
152
+
153
+ function isPotentiallyRelevantHeader(text) {
154
+ const source = text.replace(/^\uFEFF/, '').trimStart()
155
+ if (!source.startsWith('[')) return false
156
+ const decoded = source.replace(/\\u[0-9A-Fa-f]{4}|\\U[0-9A-Fa-f]{8}/g, (escape) => (
157
+ decodeBasicKey(escape) ?? escape
158
+ ))
159
+ return decoded.includes('mcp_servers')
160
+ && (decoded.includes('gemus') || source.includes('\\u') || source.includes('\\U'))
161
+ }
162
+
163
+ function startsWith(segments, root) {
164
+ return segments.length >= root.length && root.every((part, index) => segments[index] === part)
165
+ }
166
+
167
+ function isRelevantRoot(segments) {
168
+ return startsWith(segments, LEGACY_ROOT) || startsWith(segments, COMPANION_ROOT)
169
+ }
170
+
171
+ function samePath(segments, root) {
172
+ return segments.length === root.length && startsWith(segments, root)
173
+ }
174
+
175
+ function multilineDelimiter(text) {
176
+ const content = withoutComment(text)
177
+ const basic = (content.match(/"""/g) ?? []).length
178
+ const literal = (content.match(/'''/g) ?? []).length
179
+ if (basic % 2) return '"""'
180
+ if (literal % 2) return "'''"
181
+ return undefined
182
+ }
183
+
184
+ function closesMultilineBasicString(text) {
185
+ for (let index = 0; index < text.length;) {
186
+ if (text[index] !== '"') {
187
+ index += 1
188
+ continue
189
+ }
190
+ const runStart = index
191
+ while (text[index] === '"') index += 1
192
+ let backslashes = 0
193
+ for (let before = runStart - 1; before >= 0 && text[before] === '\\'; before -= 1) {
194
+ backslashes += 1
195
+ }
196
+ const escapedQuotes = backslashes % 2
197
+ if (index - runStart - escapedQuotes >= 3) return true
198
+ }
199
+ return false
200
+ }
201
+
202
+ function closesMultilineString(text, delimiter) {
203
+ return delimiter === '"""'
204
+ ? closesMultilineBasicString(text)
205
+ : text.includes(delimiter)
206
+ }
207
+
208
+ function lex(source) {
209
+ const lines = splitLines(source)
210
+ const headers = []
211
+ const codeLines = new Set()
212
+ let multiline = undefined
213
+ for (let index = 0; index < lines.length; index += 1) {
214
+ const line = lines[index]
215
+ if (!multiline) {
216
+ codeLines.add(index)
217
+ const header = parseHeader(line.text)
218
+ if (header) headers.push({ index, ...header })
219
+ const opener = multilineDelimiter(line.text)
220
+ if (opener) multiline = opener
221
+ } else if (closesMultilineString(line.text, multiline)) {
222
+ multiline = undefined
223
+ }
224
+ }
225
+ return { lines, headers, codeLines }
226
+ }
227
+
228
+ function assignment(text) {
229
+ const content = withoutComment(text)
230
+ const match = /^\s*([^=]+?)\s*=\s*(.*?)\s*$/.exec(content)
231
+ if (!match) return undefined
232
+ const segments = parseSegments(match[1])
233
+ return segments ? { segments, value: match[2] } : undefined
234
+ }
235
+
236
+ function inlineTableEntries(value) {
237
+ const content = value.trim()
238
+ if (!content.startsWith('{')) return undefined
239
+ const entries = []
240
+ let start = 1
241
+ let braces = 1
242
+ let brackets = 0
243
+ let quote = undefined
244
+ let escaped = false
245
+ for (let index = 1; index < content.length; index += 1) {
246
+ const character = content[index]
247
+ if (quote === '"' && escaped) {
248
+ escaped = false
249
+ continue
250
+ }
251
+ if (quote === '"' && character === '\\') {
252
+ escaped = true
253
+ continue
254
+ }
255
+ if (character === quote) {
256
+ quote = undefined
257
+ continue
258
+ }
259
+ if (!quote && (character === '"' || character === "'")) {
260
+ quote = character
261
+ continue
262
+ }
263
+ if (quote) continue
264
+ if (character === '#') return undefined
265
+ if (character === '{') braces += 1
266
+ else if (character === '}') {
267
+ braces -= 1
268
+ if (braces === 0) {
269
+ if (brackets !== 0 || content.slice(index + 1).trim() !== '') return undefined
270
+ const entry = content.slice(start, index).trim()
271
+ if (entry) entries.push(entry)
272
+ else if (entries.length > 0) return undefined
273
+ return entries
274
+ }
275
+ } else if (character === '[') brackets += 1
276
+ else if (character === ']') {
277
+ brackets -= 1
278
+ if (brackets < 0) return undefined
279
+ } else if (character === ',' && braces === 1 && brackets === 0) {
280
+ const entry = content.slice(start, index).trim()
281
+ if (!entry) return undefined
282
+ entries.push(entry)
283
+ start = index + 1
284
+ }
285
+ if (braces < 1) return undefined
286
+ }
287
+ return undefined
288
+ }
289
+
290
+ function inlineTableDefinesKey(value, key) {
291
+ const entries = inlineTableEntries(value)
292
+ if (!entries) return undefined
293
+ for (const entry of entries) {
294
+ const parsed = assignment(entry)
295
+ if (!parsed) return undefined
296
+ if (parsed.segments[0] === key) return true
297
+ }
298
+ return false
299
+ }
300
+
301
+ function relevantAssignment(activeHeader, parsed) {
302
+ const path = [...(activeHeader ?? []), ...parsed.segments]
303
+ if (activeHeader && startsWith(activeHeader, LEGACY_ROOT)) return undefined
304
+ const inlineTable = parsed.value.trimStart().startsWith('{')
305
+ for (const root of [LEGACY_ROOT, COMPANION_ROOT]) {
306
+ const definesRoot = samePath(path, root)
307
+ const dottedDescendant = parsed.segments.length > 1 && startsWith(path, root)
308
+ let overlappingInlineTable = inlineTable
309
+ && (startsWith(path, root) || startsWith(root, path))
310
+ if (overlappingInlineTable && root === LEGACY_ROOT && samePath(path, ['mcp_servers'])) {
311
+ overlappingInlineTable = inlineTableDefinesKey(parsed.value, 'gemus') !== false
312
+ }
313
+ if (definesRoot || dottedDescendant || overlappingInlineTable) return root
314
+ }
315
+ return undefined
316
+ }
317
+
318
+ function parseString(value) {
319
+ if (value.startsWith('"""') || value.startsWith("'''")) return undefined
320
+ if (value.startsWith('"')) {
321
+ try {
322
+ const parsed = JSON.parse(value)
323
+ return typeof parsed === 'string' ? parsed : undefined
324
+ } catch {
325
+ return undefined
326
+ }
327
+ }
328
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
329
+ return value.slice(1, -1)
330
+ }
331
+ return undefined
332
+ }
333
+
334
+ function subtreeRanges(headers, lineCount, root) {
335
+ const ranges = []
336
+ for (let headerIndex = 0; headerIndex < headers.length; headerIndex += 1) {
337
+ const header = headers[headerIndex]
338
+ if (!startsWith(header.segments, root)) continue
339
+ let end = lineCount
340
+ for (let nextIndex = headerIndex + 1; nextIndex < headers.length; nextIndex += 1) {
341
+ const next = headers[nextIndex]
342
+ if (!startsWith(next.segments, root)) {
343
+ end = next.index
344
+ break
345
+ }
346
+ }
347
+ ranges.push([header.index, end])
348
+ }
349
+ return ranges
350
+ }
351
+
352
+ function isHttpUrl(value) {
353
+ try {
354
+ const parsed = new URL(value)
355
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:'
356
+ } catch {
357
+ return false
358
+ }
359
+ }
360
+
361
+ export function inspectCodexConfig(source) {
362
+ const { lines, headers, codeLines } = lex(source)
363
+ const headersByLine = new Map(headers.map((header) => [header.index, header]))
364
+ const issues = []
365
+ const legacyHeaders = headers.filter(({ segments }) => startsWith(segments, LEGACY_ROOT))
366
+ const companionHeaders = headers.filter(({ segments }) => (
367
+ segments.length === COMPANION_ROOT.length && startsWith(segments, COMPANION_ROOT)
368
+ ))
369
+ const values = new Map()
370
+ const valueCounts = new Map()
371
+ let legacyKeyWasPresent = false
372
+ let legacyAssignmentWasPresent = false
373
+ let activeHeader = undefined
374
+
375
+ for (const [index, line] of lines.entries()) {
376
+ if (!codeLines.has(index)) continue
377
+ const header = headersByLine.get(index)
378
+ if (header) {
379
+ activeHeader = header.segments
380
+ continue
381
+ }
382
+ const parsed = assignment(line.text)
383
+ const assignmentRoot = parsed ? relevantAssignment(activeHeader, parsed) : undefined
384
+ if (assignmentRoot) {
385
+ issues.push('unsupported Gemus inline or dotted assignment')
386
+ if (assignmentRoot === LEGACY_ROOT) legacyAssignmentWasPresent = true
387
+ }
388
+ if (!activeHeader || !startsWith(activeHeader, [...LEGACY_ROOT, 'env'])) continue
389
+ if (activeHeader.length !== 3 || !parsed || parsed.segments.length !== 1) continue
390
+ const key = parsed.segments[0]
391
+ if (key !== 'GEMUS_KEY' && key !== 'GEMUS_URL') continue
392
+ if (key === 'GEMUS_KEY') legacyKeyWasPresent = true
393
+ const count = (valueCounts.get(key) ?? 0) + 1
394
+ valueCounts.set(key, count)
395
+ if (count > 1) {
396
+ values.delete(key)
397
+ continue
398
+ }
399
+ const value = parseString(parsed.value)
400
+ if (value === undefined) continue
401
+ if (key === 'GEMUS_URL' && !isHttpUrl(value)) continue
402
+ values.set(key, value)
403
+ }
404
+
405
+ for (const [index, line] of lines.entries()) {
406
+ if (!codeLines.has(index)) continue
407
+ const content = line.text.replace(/^\uFEFF/, '').trimStart()
408
+ if (isPotentiallyRelevantHeader(content)) {
409
+ if (!parseHeader(line.text)) issues.push('malformed Gemus header')
410
+ }
411
+ }
412
+
413
+ for (const header of headers) {
414
+ if (header.array && isRelevantRoot(header.segments)) {
415
+ issues.push('unsupported Gemus array table')
416
+ }
417
+ }
418
+
419
+ const enabledValues = []
420
+ activeHeader = undefined
421
+ for (const [index, line] of lines.entries()) {
422
+ if (!codeLines.has(index)) continue
423
+ const header = headersByLine.get(index)
424
+ if (header) {
425
+ activeHeader = header.segments
426
+ continue
427
+ }
428
+ if (!activeHeader || activeHeader.length !== COMPANION_ROOT.length || !startsWith(activeHeader, COMPANION_ROOT)) continue
429
+ const parsed = assignment(line.text)
430
+ if (parsed?.segments.length === 1 && parsed.segments[0] === 'enabled') enabledValues.push(parsed.value.trim())
431
+ }
432
+
433
+ return {
434
+ hasLegacyGemus: legacyHeaders.length > 0 || legacyAssignmentWasPresent,
435
+ enabledPluginCompanion: companionHeaders.length > 0 && enabledValues.includes('true'),
436
+ legacyKey: values.get('GEMUS_KEY'),
437
+ legacyKeyWasPresent,
438
+ legacyUrl: values.get('GEMUS_URL'),
439
+ issues: [...new Set(issues)],
440
+ companionSectionCount: companionHeaders.length,
441
+ enabledValueCount: enabledValues.length,
442
+ }
443
+ }
444
+
445
+ function companionText(newline, preservedBody = '') {
446
+ const bodySeparator = preservedBody && !/(\r\n|\n|\r)$/.test(preservedBody) ? newline : ''
447
+ return `[plugins."gemus@gemus".mcp_servers.gemus]${newline}${preservedBody}${bodySeparator}enabled = true${newline}`
448
+ }
449
+
450
+ function standaloneComment(line) {
451
+ const index = commentIndex(line.text)
452
+ if (index === -1) return ''
453
+ const indentation = /^\s*/.exec(line.text)?.[0] ?? ''
454
+ return `${indentation}${line.text.slice(index)}${line.raw.slice(line.text.length)}`
455
+ }
456
+
457
+ function migrationResult(source, nextText, inspection) {
458
+ return {
459
+ changed: nextText !== source,
460
+ nextText,
461
+ text: nextText,
462
+ removedLegacy: inspection.hasLegacyGemus,
463
+ legacyKey: inspection.legacyKey,
464
+ legacyKeyWasPresent: inspection.legacyKeyWasPresent,
465
+ legacyUrl: inspection.legacyUrl,
466
+ enabledPluginCompanion: true,
467
+ }
468
+ }
469
+
470
+ export function planCodexConfigMigration(source) {
471
+ const inspection = inspectCodexConfig(source)
472
+ if (inspection.issues.length) {
473
+ throw new Error(`cannot safely migrate Gemus config: ${inspection.issues.join(', ')}`)
474
+ }
475
+
476
+ const alreadyCanonical = !inspection.hasLegacyGemus
477
+ && inspection.companionSectionCount === 1
478
+ && inspection.enabledValueCount === 1
479
+ && inspection.enabledPluginCompanion
480
+ if (alreadyCanonical) {
481
+ return migrationResult(source, source, inspection)
482
+ }
483
+
484
+ const bom = source.startsWith('\uFEFF') ? '\uFEFF' : ''
485
+ const bodySource = bom ? source.slice(1) : source
486
+ const { lines, headers, codeLines } = lex(bodySource)
487
+ const removed = new Set()
488
+ for (const [start, end] of subtreeRanges(headers, lines.length, LEGACY_ROOT)) {
489
+ for (let index = start; index < end; index += 1) removed.add(index)
490
+ }
491
+
492
+ const directCompanionHeaders = headers
493
+ .map((header, index) => ({ ...header, headerIndex: index }))
494
+ .filter(({ segments }) => segments.length === COMPANION_ROOT.length && startsWith(segments, COMPANION_ROOT))
495
+ const newline = bodySource.includes('\r\n') ? '\r\n' : bodySource.includes('\r') ? '\r' : '\n'
496
+ let companionBody = ''
497
+ for (const header of directCompanionHeaders) {
498
+ const end = headers[header.headerIndex + 1]?.index ?? lines.length
499
+ for (let index = header.index; index < end; index += 1) {
500
+ removed.add(index)
501
+ if (index === header.index) {
502
+ companionBody += standaloneComment(lines[index])
503
+ continue
504
+ }
505
+ const parsed = codeLines.has(index) ? assignment(lines[index].text) : undefined
506
+ if (parsed?.segments.length === 1 && parsed.segments[0] === 'enabled') {
507
+ companionBody += standaloneComment(lines[index])
508
+ continue
509
+ }
510
+ companionBody += lines[index].raw
511
+ }
512
+ }
513
+
514
+ const block = companionText(newline, companionBody)
515
+ const insertionIndex = directCompanionHeaders[0]?.index
516
+ let body = ''
517
+ for (const [index, line] of lines.entries()) {
518
+ if (index === insertionIndex) body += block
519
+ if (!removed.has(index)) body += line.raw
520
+ }
521
+ if (insertionIndex === undefined) {
522
+ const separator = body === '' ? '' : /(\r\n|\n|\r)$/.test(body) ? newline : `${newline}${newline}`
523
+ body += `${separator}${block}`
524
+ }
525
+
526
+ const hadTrailingNewline = bodySource === '' || /(\r\n|\n|\r)$/.test(bodySource)
527
+ if (!hadTrailingNewline) body = body.replace(/(\r\n|\n|\r)$/, '')
528
+ const nextText = `${bom}${body}`
529
+ return migrationResult(source, nextText, inspection)
530
+ }