@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.
- package/README.md +41 -21
- package/package.json +3 -2
- package/src/proxy.mjs +29 -427
- package/src/relay.mjs +440 -0
- package/src/setup/cli.mjs +130 -0
- package/src/setup/codex.mjs +110 -0
- package/src/setup/config.mjs +530 -0
- package/src/setup/configFile.mjs +155 -0
- package/src/setup/environment.mjs +79 -0
- package/src/setup/secret.mjs +91 -0
- package/src/__tests__/backfill.test.ts +0 -394
- package/src/__tests__/failFast.test.ts +0 -356
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +0 -216
- package/src/__tests__/mcpHeaders.test.ts +0 -32
- package/src/__tests__/proxyMessages.test.ts +0 -81
- package/src/__tests__/route.integration.test.ts +0 -68
- package/src/__tests__/startup.test.ts +0 -752
- package/src/__tests__/upstreamHttp.real.test.ts +0 -37
- package/src/__tests__/upstreamHttp.test.ts +0 -151
|
@@ -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
|
+
}
|