@juspay/neurolink 12.30.0 → 12.30.1

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/CHANGELOG.md CHANGED
@@ -1,8 +1,8 @@
1
- ## [12.30.0](https://github.com/juspay/neurolink/compare/v12.29.2...v12.30.0) (2026-09-26)
1
+ ## [12.30.1](https://github.com/juspay/neurolink/compare/v12.30.0...v12.30.1) (2026-09-26)
2
2
 
3
- ### Features
3
+ ### Bug Fixes
4
4
 
5
- - **(video):** honour videoOptions end to end, timestamp keyframes, transcribe speech ([ab037cc](https://github.com/juspay/neurolink/commit/ab037cc1ecce885c9a4bd64f6ca020021f24a038)), closes [#1720](https://github.com/juspay/neurolink/issues/1720) [#478](https://github.com/juspay/neurolink/issues/478) [#460](https://github.com/juspay/neurolink/issues/460) [#433](https://github.com/juspay/neurolink/issues/433) [#433](https://github.com/juspay/neurolink/issues/433) [#460](https://github.com/juspay/neurolink/issues/460)
5
+ - **(proxy):** stop Grok's config gaining a second catalog on every start ([a92dd0b](https://github.com/juspay/neurolink/commit/a92dd0b93ff6bda3522d870a72436dbf6ae2bbe8))
6
6
 
7
7
  ## [11.2.3](https://github.com/juspay/neurolink/compare/v11.2.2...v11.2.3) (2026-08-19)
8
8
 
@@ -8,6 +8,7 @@ import { homedir } from "os";
8
8
  import { join } from "path";
9
9
  import { logger } from "../../utils/logger.js";
10
10
  import { writeFileAtomic } from "./snapshot.js";
11
+ import { dottedTableIds, findDuplicateTable, ownedLineCount, readStringValue, splitTomlSections, } from "./tomlSections.js";
11
12
  //
12
13
  // Points the Codex CLI at the proxy by managing `~/.codex/config.toml`:
13
14
  // - appends a marker-delimited `[model_providers.neurolink]` table
@@ -28,18 +29,67 @@ function getCodexSnapshotPath() {
28
29
  }
29
30
  const CODEX_BLOCK_BEGIN = "# >>> neurolink-proxy (managed) >>>";
30
31
  const CODEX_BLOCK_END = "# <<< neurolink-proxy (managed) <<<";
32
+ const CODEX_MANAGED_COMMENT_LINES = new Set([
33
+ CODEX_BLOCK_BEGIN,
34
+ CODEX_BLOCK_END,
35
+ ]);
36
+ const CODEX_PROVIDER_ID = "neurolink";
37
+ const CODEX_PROVIDER_NAME = "NeuroLink Proxy";
31
38
  const CODEX_PROVIDER_LINE_RE = /^[ \t]*model_provider[ \t]*=.*$/m;
32
39
  const CODEX_MODEL_LINE_RE = /^[ \t]*model[ \t]*=.*$/m;
33
- /** Strip any previously-managed block + our injected provider line. */
34
- function stripCodexManagedConfig(text) {
35
- const blockRe = new RegExp(`\\n?${escapeRegExp(CODEX_BLOCK_BEGIN)}[\\s\\S]*?${escapeRegExp(CODEX_BLOCK_END)}\\n?`, "g");
36
- let out = text.replace(blockRe, "\n");
37
- // Remove our injected selector line (only the exact "neurolink" one).
38
- out = out.replace(/^[ \t]*model_provider[ \t]*=[ \t]*"neurolink"[ \t]*$\n?/m, "");
39
- return out;
40
+ const CODEX_MANAGED_SELECTOR_RE = /^[ \t]*model_provider[ \t]*=[ \t]*"neurolink"[ \t]*$\n?/m;
41
+ /**
42
+ * What of a Codex config.toml the proxy owns, found by content rather than
43
+ * by the block markers. Codex's own saves keep comments (measured on
44
+ * codex-cli 0.155.1), but a hand edit or another tool need not, and a
45
+ * marker-only writer then appended a second `[model_providers.neurolink]`,
46
+ * which Codex cannot parse. The proxy's provider is that table carrying the
47
+ * name the proxy writes, with any sub-tables under it. `foreign` is set when
48
+ * a `neurolink` provider exists that the proxy did not write: another name, a
49
+ * dotted-key or array definition, or sub-tables with no provider table, which
50
+ * the proxy never writes. Writing ours beside it would declare the table twice
51
+ * or adopt the user's keys. `remainder` keeps every byte the proxy does not
52
+ * own, including comments the user wrote after its table.
53
+ */
54
+ function inspectCodexConfig(text) {
55
+ const sections = splitTomlSections(text);
56
+ const isProvider = (section) => section.path !== null &&
57
+ section.path.length >= 2 &&
58
+ section.path[0] === "model_providers" &&
59
+ section.path[1] === CODEX_PROVIDER_ID;
60
+ const tables = sections.filter((section) => isProvider(section) && !section.arrayTable && section.path?.length === 2);
61
+ const ours = (section) => readStringValue(section, "name") === CODEX_PROVIDER_NAME;
62
+ const foreign = tables.some((table) => !ours(table)) ||
63
+ (tables.length === 0 && sections.some(isProvider)) ||
64
+ sections.some((section) => isProvider(section) && section.arrayTable) ||
65
+ dottedTableIds(sections, "model_providers").includes(CODEX_PROVIDER_ID);
66
+ const owned = sections.map((section) => isProvider(section) &&
67
+ !section.arrayTable &&
68
+ (section.path?.length === 2 ? ours(section) : !foreign));
69
+ const managedBaseUrls = [
70
+ ...new Set(sections.flatMap((section, index) => {
71
+ const url = owned[index]
72
+ ? readStringValue(section, "base_url")
73
+ : undefined;
74
+ return url === undefined ? [] : [url];
75
+ })),
76
+ ];
77
+ const remainder = sections
78
+ .flatMap((section, index) => owned[index]
79
+ ? section.lines.slice(ownedLineCount(section, CODEX_MANAGED_COMMENT_LINES))
80
+ : section.lines)
81
+ .filter((line) => !CODEX_MANAGED_COMMENT_LINES.has(line.trim()))
82
+ .join("");
83
+ return { remainder, managedBaseUrls, foreign };
40
84
  }
41
- function escapeRegExp(value) {
42
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85
+ /**
86
+ * Remove the selector the proxy injected. Only the preamble holds top-level
87
+ * keys: a profile's own `model_provider = "neurolink"` is the user's.
88
+ */
89
+ function stripCodexSelector(text) {
90
+ return editTomlPreamble(text, (preamble) => CODEX_MANAGED_SELECTOR_RE.test(preamble)
91
+ ? preamble.replace(CODEX_MANAGED_SELECTOR_RE, "")
92
+ : null);
43
93
  }
44
94
  /**
45
95
  * Apply a top-level TOML key edit, confined to the document preamble.
@@ -60,8 +110,8 @@ function editTomlPreamble(text, edit) {
60
110
  function buildCodexProviderBlock(baseUrl) {
61
111
  return [
62
112
  CODEX_BLOCK_BEGIN,
63
- "[model_providers.neurolink]",
64
- 'name = "NeuroLink Proxy"',
113
+ `[model_providers.${CODEX_PROVIDER_ID}]`,
114
+ `name = "${CODEX_PROVIDER_NAME}"`,
65
115
  `base_url = "${baseUrl}/backend-api/codex"`,
66
116
  'wire_api = "responses"',
67
117
  "requires_openai_auth = true",
@@ -77,6 +127,37 @@ export async function setCodexProxySettings(baseUrl) {
77
127
  return false;
78
128
  }
79
129
  const original = fs.readFileSync(getCodexConfigPath(), "utf8");
130
+ const inspected = inspectCodexConfig(original);
131
+ if (inspected.foreign) {
132
+ logger.warn("[proxy] Codex: config.toml already defines a neurolink provider the proxy did not write; leaving it untouched");
133
+ return false;
134
+ }
135
+ let text = stripCodexSelector(inspected.remainder);
136
+ // Set the selector: replace an existing top-level model_provider or insert
137
+ // one right after the top-level `model = ...` line (stays before any table).
138
+ let selectorPlaced = false;
139
+ text = editTomlPreamble(text, (preamble) => {
140
+ if (CODEX_PROVIDER_LINE_RE.test(preamble)) {
141
+ selectorPlaced = true;
142
+ return preamble.replace(CODEX_PROVIDER_LINE_RE, 'model_provider = "neurolink"');
143
+ }
144
+ if (CODEX_MODEL_LINE_RE.test(preamble)) {
145
+ selectorPlaced = true;
146
+ return preamble.replace(CODEX_MODEL_LINE_RE, (line) => `${line}\nmodel_provider = "neurolink"`);
147
+ }
148
+ return null;
149
+ });
150
+ if (!selectorPlaced) {
151
+ text = `model_provider = "neurolink"\n${text}`;
152
+ }
153
+ const next = `${text.replace(/\s*$/, "\n")}\n${buildCodexProviderBlock(baseUrl)}`;
154
+ // Codex will not load a config that declares a table twice. Writing into
155
+ // one would only bury the user's own error under ours.
156
+ const duplicate = findDuplicateTable(next);
157
+ if (duplicate !== undefined) {
158
+ logger.warn(`[proxy] Codex: config.toml declares [${duplicate}] more than once; leaving it untouched`);
159
+ return false;
160
+ }
80
161
  // Snapshot the user's original selector once (survives crashes/restarts).
81
162
  if (!fs.existsSync(getCodexSnapshotPath())) {
82
163
  // Same preamble boundary the edit paths use. Matching document-wide would
@@ -95,26 +176,11 @@ export async function setCodexProxySettings(baseUrl) {
95
176
  fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
96
177
  await writeFileAtomic(getCodexSnapshotPath(), JSON.stringify({ originalProviderLine }, null, 2), 0o600);
97
178
  }
98
- let text = stripCodexManagedConfig(original);
99
- // Set the selector: replace an existing top-level model_provider or insert
100
- // one right after the top-level `model = ...` line (stays before any table).
101
- let selectorPlaced = false;
102
- text = editTomlPreamble(text, (preamble) => {
103
- if (CODEX_PROVIDER_LINE_RE.test(preamble)) {
104
- selectorPlaced = true;
105
- return preamble.replace(CODEX_PROVIDER_LINE_RE, 'model_provider = "neurolink"');
106
- }
107
- if (CODEX_MODEL_LINE_RE.test(preamble)) {
108
- selectorPlaced = true;
109
- return preamble.replace(CODEX_MODEL_LINE_RE, (line) => `${line}\nmodel_provider = "neurolink"`);
110
- }
111
- return null;
112
- });
113
- if (!selectorPlaced) {
114
- text = `model_provider = "neurolink"\n${text}`;
179
+ // Every proxy start applies. Rewriting a file that already matches only
180
+ // widens the window for racing Codex's own saves.
181
+ if (next !== original) {
182
+ await writeFileAtomic(getCodexConfigPath(), next);
115
183
  }
116
- const trimmed = text.replace(/\s*$/, "\n");
117
- await writeFileAtomic(getCodexConfigPath(), `${trimmed}\n${buildCodexProviderBlock(baseUrl)}`);
118
184
  return true;
119
185
  }
120
186
  catch (error) {
@@ -129,19 +195,23 @@ export async function clearCodexProxySettings(expectedBaseUrl) {
129
195
  return false;
130
196
  }
131
197
  const original = fs.readFileSync(getCodexConfigPath(), "utf8");
132
- // The managed block records its owner in `base_url`. Without this check a
133
- // second proxy shutting down on another port would strip the block that the
134
- // still-running proxy installed, and restore the snapshot selector on top —
135
- // silently taking Codex off the live proxy. Mirrors the Claude and OpenCode
136
- // clear paths.
137
- if (expectedBaseUrl) {
138
- const ownerMatch = original.match(new RegExp(`${escapeRegExp(CODEX_BLOCK_BEGIN)}[\\s\\S]*?base_url\\s*=\\s*"([^"]*)"`));
139
- if (ownerMatch &&
140
- ownerMatch[1] !== `${expectedBaseUrl}/backend-api/codex`) {
141
- return false;
142
- }
198
+ const inspected = inspectCodexConfig(original);
199
+ // Apply refuses such a config, so the selector pointing at the user's own
200
+ // provider is theirs as well.
201
+ if (inspected.foreign) {
202
+ return false;
203
+ }
204
+ // The managed table records its owner in `base_url`. Without this check a
205
+ // second proxy shutting down on another port would strip the table that
206
+ // the still-running proxy installed, and restore the snapshot selector on
207
+ // top — silently taking Codex off the live proxy. Mirrors the Claude and
208
+ // OpenCode clear paths.
209
+ if (expectedBaseUrl &&
210
+ inspected.managedBaseUrls.length > 0 &&
211
+ !inspected.managedBaseUrls.includes(`${expectedBaseUrl}/backend-api/codex`)) {
212
+ return false;
143
213
  }
144
- let text = stripCodexManagedConfig(original);
214
+ let text = stripCodexSelector(inspected.remainder);
145
215
  // Restore the user's original selector line if we snapshotted one.
146
216
  let restored = null;
147
217
  if (fs.existsSync(getCodexSnapshotPath())) {
@@ -18,6 +18,16 @@
18
18
  * Adaptive thinking: Grok's global `default_reasoning_effort = "xhigh"`
19
19
  * becomes Anthropic `thinking.type = "adaptive"`. Haiku 4.5 rejects that
20
20
  * with 400. Only Claude Opus/Sonnet 4.6 and 5.x keep reasoning enabled.
21
+ *
22
+ * Ownership is decided by content, never by the block markers alone. Grok
23
+ * rewrites config.toml on its own saves (`/settings`, `grok mcp add`, ...)
24
+ * through a serializer that drops every comment, the markers included, and
25
+ * splits the inline `extra_headers` into a `[model.<id>.extra_headers]`
26
+ * sub-table (measured on Grok Build 1.0.40). A marker-only writer then kept
27
+ * the unmarked tables and appended a second copy of every id on the next
28
+ * proxy start, and TOML rejects a table declared twice, so Grok would not
29
+ * start. What marks an entry as ours is the placeholder key, a credential
30
+ * only the proxy accepts, and that survives Grok's rewrite.
21
31
  */
22
32
  import type { CliGrokProxyModelSpec, CliProxyClientApplyOptions, CliProxyClientConfigurator, ModelMapping } from "../../types/index.js";
23
33
  declare function getGrokConfigDir(): string;
@@ -26,7 +36,11 @@ declare function getGrokSnapshotPath(): string;
26
36
  declare function classifyGrokProxyModel(id: string, mapping?: ModelMapping): CliGrokProxyModelSpec;
27
37
  declare function loadRoutedModelIds(configPath?: string): Promise<string[]>;
28
38
  declare function catalogModelIds(configPath?: string): Promise<string[]>;
29
- declare function buildGrokManagedBlock(baseUrl: string, configPath?: string): Promise<string>;
39
+ /**
40
+ * `userModelIds` are ids the user defines themselves. Writing the proxy's
41
+ * entry beside theirs would declare the table twice, so theirs wins.
42
+ */
43
+ declare function buildGrokManagedBlock(baseUrl: string, configPath?: string, userModelIds?: ReadonlySet<string>): Promise<string>;
30
44
  export declare function setGrokProxySettings(baseUrl: string, options?: CliProxyClientApplyOptions): Promise<boolean>;
31
45
  export declare function clearGrokProxySettings(expectedBaseUrl?: string): Promise<boolean>;
32
46
  export declare const grokConfigurator: CliProxyClientConfigurator;
@@ -18,6 +18,16 @@
18
18
  * Adaptive thinking: Grok's global `default_reasoning_effort = "xhigh"`
19
19
  * becomes Anthropic `thinking.type = "adaptive"`. Haiku 4.5 rejects that
20
20
  * with 400. Only Claude Opus/Sonnet 4.6 and 5.x keep reasoning enabled.
21
+ *
22
+ * Ownership is decided by content, never by the block markers alone. Grok
23
+ * rewrites config.toml on its own saves (`/settings`, `grok mcp add`, ...)
24
+ * through a serializer that drops every comment, the markers included, and
25
+ * splits the inline `extra_headers` into a `[model.<id>.extra_headers]`
26
+ * sub-table (measured on Grok Build 1.0.40). A marker-only writer then kept
27
+ * the unmarked tables and appended a second copy of every id on the next
28
+ * proxy start, and TOML rejects a table declared twice, so Grok would not
29
+ * start. What marks an entry as ours is the placeholder key, a credential
30
+ * only the proxy accepts, and that survives Grok's rewrite.
21
31
  */
22
32
  import { readFileSync } from "fs";
23
33
  import { homedir } from "os";
@@ -26,9 +36,19 @@ import { logger } from "../../utils/logger.js";
26
36
  import { getContextWindowSize } from "../../constants/contextWindows.js";
27
37
  import { DEFAULT_PROXY_MODEL_IDS } from "../../constants/proxyModels.js";
28
38
  import { defaultProxyConfigPath, parseProxyConfigString, } from "../../proxy/proxyConfig.js";
29
- import { isUsableSnapshot, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js";
39
+ import { isUsableSnapshot, resolveSymlinkTarget, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js";
40
+ import { dottedTableIds, findDuplicateTable, inlineTablePairs, maskTomlStrings, maskedStringValue, ownedLineCount, parseMaskedAssignment, readStringValue, splitTomlSections, tomlCodeLines, } from "./tomlSections.js";
30
41
  const GROK_BLOCK_BEGIN = "# >>> neurolink-proxy (managed) >>>";
31
42
  const GROK_BLOCK_END = "# <<< neurolink-proxy (managed) <<<";
43
+ const GROK_BLOCK_NOTES = [
44
+ "# Proxy catalog. Built-in grok-4.6 / grok-4.5 stay on xAI.",
45
+ "# context_window is Grok's compaction limit and must be <= upstream.",
46
+ ];
47
+ const GROK_MANAGED_COMMENT_LINES = new Set([
48
+ GROK_BLOCK_BEGIN,
49
+ GROK_BLOCK_END,
50
+ ...GROK_BLOCK_NOTES,
51
+ ]);
32
52
  const PLACEHOLDER_KEY = "neurolink-proxy";
33
53
  const ANTHROPIC_VERSION = "2023-06-01";
34
54
  function getGrokConfigDir() {
@@ -43,9 +63,6 @@ function getGrokConfigPath() {
43
63
  function getGrokSnapshotPath() {
44
64
  return join(homedir(), ".neurolink", "grok-proxy-snapshot.json");
45
65
  }
46
- function escapeRegExp(value) {
47
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
- }
49
66
  function tomlKey(id) {
50
67
  return /^[A-Za-z0-9_-]+$/.test(id) ? id : JSON.stringify(id);
51
68
  }
@@ -172,28 +189,148 @@ function buildGrokModelBlock(spec, baseUrl) {
172
189
  }
173
190
  return lines.join("\n");
174
191
  }
175
- async function buildGrokManagedBlock(baseUrl, configPath) {
176
- const specs = await catalogGrokSpecs(configPath);
177
- const body = specs
192
+ /**
193
+ * `userModelIds` are ids the user defines themselves. Writing the proxy's
194
+ * entry beside theirs would declare the table twice, so theirs wins.
195
+ */
196
+ async function buildGrokManagedBlock(baseUrl, configPath, userModelIds = new Set()) {
197
+ const catalog = await catalogGrokSpecs(configPath);
198
+ for (const spec of catalog) {
199
+ if (userModelIds.has(spec.id)) {
200
+ logger.debug(`[proxy] Grok: config.toml already defines [model.${tomlKey(spec.id)}]; keeping it and skipping the proxy's entry`);
201
+ }
202
+ }
203
+ const body = catalog
204
+ .filter((spec) => !userModelIds.has(spec.id))
178
205
  .map((spec) => buildGrokModelBlock(spec, baseUrl))
179
206
  .join("\n\n");
180
207
  return [
181
208
  GROK_BLOCK_BEGIN,
182
- "# Proxy catalog. Built-in grok-4.6 / grok-4.5 stay on xAI.",
183
- "# context_window is Grok's compaction limit and must be <= upstream.",
209
+ ...GROK_BLOCK_NOTES,
184
210
  "",
185
211
  body,
186
212
  GROK_BLOCK_END,
187
213
  "",
188
214
  ].join("\n");
189
215
  }
190
- function stripGrokManagedBlock(text) {
191
- const blockRe = new RegExp(`\\n?${escapeRegExp(GROK_BLOCK_BEGIN)}[\\s\\S]*?${escapeRegExp(GROK_BLOCK_END)}\\n?`, "g");
192
- return text.replace(blockRe, "\n");
216
+ function modelEntryId(section) {
217
+ return section.path !== null &&
218
+ section.path.length >= 2 &&
219
+ section.path[0] === "model"
220
+ ? section.path[1]
221
+ : null;
222
+ }
223
+ /** Where a model entry carries the placeholder key, as a path within it. */
224
+ const PLACEHOLDER_KEY_PATHS = [
225
+ ["api_key"],
226
+ ["extra_headers", "x-api-key"],
227
+ ];
228
+ function isPlaceholderKey(path, value) {
229
+ return (value === PLACEHOLDER_KEY &&
230
+ PLACEHOLDER_KEY_PATHS.some((keyPath) => keyPath.length === path.length &&
231
+ keyPath.every((part, index) => path[index] === part)));
232
+ }
233
+ /**
234
+ * Whether a section of a model entry sends the placeholder key: `api_key` on
235
+ * the model, or `x-api-key` in its `extra_headers`, spelled as a plain key, a
236
+ * dotted key, an inline table or the sub-table Grok's own save writes. The
237
+ * full key path must match, so a user's `legacy.api_key` is not the proxy's.
238
+ */
239
+ function sectionSendsPlaceholderKey(section) {
240
+ const prefix = section.path?.slice(2) ?? [];
241
+ return tomlCodeLines(section).some(({ line, statement }) => {
242
+ if (!statement) {
243
+ return false;
244
+ }
245
+ const { masked, strings } = maskTomlStrings(line);
246
+ const assignment = parseMaskedAssignment(masked, strings);
247
+ if (!assignment) {
248
+ return false;
249
+ }
250
+ const path = [...prefix, ...assignment.path];
251
+ return (isPlaceholderKey(path, maskedStringValue(assignment.value, strings)) ||
252
+ inlineTablePairs(assignment.value, strings).some((pair) => isPlaceholderKey([...path, ...pair.path], maskedStringValue(pair.value, strings))));
253
+ });
193
254
  }
194
- function extractManagedBaseUrl(text) {
195
- const match = text.match(new RegExp(`${escapeRegExp(GROK_BLOCK_BEGIN)}[\\s\\S]*?base_url\\s*=\\s*"([^"]*)"`));
196
- return match?.[1];
255
+ /**
256
+ * Which parts of a Grok config.toml the proxy owns.
257
+ *
258
+ * A model entry is a `[model.<id>]` table plus its `[model.<id>.*]`
259
+ * sub-tables, wherever they sit: Grok's save keeps them together, hand edits
260
+ * need not. It is the proxy's when it sends the placeholder key and is either
261
+ * in the current catalog or still carries the exact name the proxy gave it,
262
+ * which covers entries a later catalog dropped. A user's own model behind the
263
+ * proxy, under their own name, is theirs. Keys and values are read as TOML,
264
+ * never as raw text, so a string that merely mentions the placeholder cannot
265
+ * make a user's table look like the proxy's. `remainder` is the text with
266
+ * owned entries and the managed comment lines removed and every other byte
267
+ * kept, so a missing, orphaned or doubled marker cannot make an apply delete
268
+ * the user's content or leave an old copy of the catalog behind.
269
+ */
270
+ function inspectGrokConfig(text, catalogIds) {
271
+ const sections = splitTomlSections(text);
272
+ // Models the user defines without a [model.<id>] table of their own.
273
+ const definedElsewhere = new Set([
274
+ ...dottedTableIds(sections, "model"),
275
+ ...sections.flatMap((section) => {
276
+ const id = section.arrayTable ? modelEntryId(section) : null;
277
+ return id !== null && section.path?.length === 2 ? [id] : [];
278
+ }),
279
+ ]);
280
+ const ids = sections.map((section) => section.arrayTable ? null : modelEntryId(section));
281
+ const isModelTable = (index) => ids[index] !== null && sections[index].path?.length === 2;
282
+ const tableFor = (index) => {
283
+ if (ids[index] === null || isModelTable(index)) {
284
+ return index;
285
+ }
286
+ const sameModel = (candidate) => isModelTable(candidate) && ids[candidate] === ids[index];
287
+ const before = sections.findLastIndex((_, candidate) => candidate < index && sameModel(candidate));
288
+ const after = sections.findIndex((_, candidate) => candidate > index && sameModel(candidate));
289
+ return before !== -1 ? before : after !== -1 ? after : index;
290
+ };
291
+ const entryOf = sections.map((_, index) => tableFor(index));
292
+ const ownedEntries = new Set(entryOf.filter((entry) => {
293
+ const id = ids[entry];
294
+ if (id === null) {
295
+ return false;
296
+ }
297
+ const sendsPlaceholder = sections.some((section, index) => entryOf[index] === entry &&
298
+ ids[index] !== null &&
299
+ sectionSendsPlaceholderKey(section));
300
+ if (!sendsPlaceholder) {
301
+ return false;
302
+ }
303
+ // A sub-table left without its table carries no name to check; it is
304
+ // the proxy's unless the model is defined some other way.
305
+ if (!isModelTable(entry)) {
306
+ return !definedElsewhere.has(id);
307
+ }
308
+ return (catalogIds.has(id) ||
309
+ readStringValue(sections[entry], "name") === displayNameFor(id));
310
+ }));
311
+ const owned = sections.map((_, index) => ids[index] !== null && ownedEntries.has(entryOf[index]));
312
+ const userModelIds = new Set([
313
+ ...definedElsewhere,
314
+ ...sections.flatMap((section, index) => {
315
+ const id = modelEntryId(section);
316
+ return id !== null && !owned[index] ? [id] : [];
317
+ }),
318
+ ]);
319
+ const managedBaseUrls = [
320
+ ...new Set(sections.flatMap((section, index) => {
321
+ const url = owned[index]
322
+ ? readStringValue(section, "base_url")
323
+ : undefined;
324
+ return url === undefined ? [] : [url];
325
+ })),
326
+ ];
327
+ const remainder = sections
328
+ .flatMap((section, index) => owned[index]
329
+ ? section.lines.slice(ownedLineCount(section, GROK_MANAGED_COMMENT_LINES))
330
+ : section.lines)
331
+ .filter((line) => !GROK_MANAGED_COMMENT_LINES.has(line.trim()))
332
+ .join("");
333
+ return { remainder, managedBaseUrls, userModelIds };
197
334
  }
198
335
  async function readGrokSnapshot() {
199
336
  const fs = await import("fs");
@@ -250,11 +387,26 @@ export async function setGrokProxySettings(baseUrl, options) {
250
387
  : original.indexOf(GROK_BLOCK_END) + GROK_BLOCK_END.length)
251
388
  : undefined
252
389
  : undefined;
390
+ const inspected = original === null
391
+ ? null
392
+ : inspectGrokConfig(original, new Set(await catalogModelIds(options?.configPath)));
393
+ const block = await buildGrokManagedBlock(baseUrl, options?.configPath, inspected?.userModelIds);
394
+ const remainder = inspected?.remainder ?? "";
395
+ const next = remainder.trim().length === 0
396
+ ? block
397
+ : `${remainder.replace(/\s*$/, "\n")}\n${block}`;
398
+ // Grok refuses to load or save a config it cannot parse. Writing into one
399
+ // would only bury the user's own error under ours.
400
+ const duplicate = findDuplicateTable(next);
401
+ if (duplicate !== undefined) {
402
+ logger.warn(`[proxy] Grok: config.toml declares [${duplicate}] more than once; leaving it untouched`);
403
+ return false;
404
+ }
253
405
  if (existingSnapshot === null ||
254
406
  shouldCaptureSnapshot({
255
407
  hasSnapshot: existingSnapshot !== null,
256
408
  written: existingSnapshot?.writtenBaseUrl,
257
- current: extractManagedBaseUrl(original ?? "") ?? currentBlock,
409
+ current: inspected?.managedBaseUrls[0] ?? currentBlock,
258
410
  })) {
259
411
  fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
260
412
  await writeFileAtomic(getGrokSnapshotPath(), JSON.stringify({
@@ -262,12 +414,11 @@ export async function setGrokProxySettings(baseUrl, options) {
262
414
  writtenBaseUrl: baseUrl,
263
415
  }, null, 2), 0o600);
264
416
  }
265
- const withoutBlock = original ? stripGrokManagedBlock(original) : "";
266
- const trimmed = withoutBlock.replace(/\s*$/, "\n");
267
- const next = `${trimmed}\n${await buildGrokManagedBlock(baseUrl, options?.configPath)}`;
268
- await writeFileAtomic(getGrokConfigPath(), next.startsWith("\n") && original === null
269
- ? next.replace(/^\n+/, "")
270
- : next, original === null ? 0o600 : undefined);
417
+ // Every proxy start applies; an update starts one. Rewriting a file that
418
+ // already matches only widens the window for racing Grok's own saves.
419
+ if (next !== original) {
420
+ await writeFileAtomic(getGrokConfigPath(), next, original === null ? 0o600 : undefined);
421
+ }
271
422
  return true;
272
423
  }
273
424
  export async function clearGrokProxySettings(expectedBaseUrl) {
@@ -279,13 +430,16 @@ export async function clearGrokProxySettings(expectedBaseUrl) {
279
430
  catch {
280
431
  return false;
281
432
  }
282
- if (!current.includes(GROK_BLOCK_BEGIN)) {
433
+ const inspected = inspectGrokConfig(current, new Set(await catalogModelIds()));
434
+ if (inspected.remainder === current) {
283
435
  return false;
284
436
  }
285
- const configuredUrl = extractManagedBaseUrl(current);
437
+ // Another proxy's block is left alone. One entry the user repointed does
438
+ // not make the rest foreign, whichever entry happens to come first.
439
+ const configuredUrls = inspected.managedBaseUrls;
286
440
  if (expectedBaseUrl &&
287
- configuredUrl !== undefined &&
288
- configuredUrl !== expectedBaseUrl) {
441
+ configuredUrls.length > 0 &&
442
+ !configuredUrls.includes(expectedBaseUrl)) {
289
443
  logger.debug("[proxy] Grok clear: base URL is not the one we wrote, leaving it intact");
290
444
  return false;
291
445
  }
@@ -294,9 +448,13 @@ export async function clearGrokProxySettings(expectedBaseUrl) {
294
448
  logger.warn("[proxy] Grok clear: no usable snapshot, leaving config.toml untouched rather than stripping a block we cannot prove we own");
295
449
  return false;
296
450
  }
297
- const remainder = stripGrokManagedBlock(current).replace(/\s*$/, "\n");
451
+ const remainder = inspected.remainder.replace(/\s*$/, "\n");
298
452
  if (!snapshot.originalExisted && remainder.trim().length === 0) {
299
- fs.rmSync(getGrokConfigPath(), { force: true });
453
+ // Apply wrote through a symlink the user already had, so the link stays
454
+ // and the file apply created behind it goes.
455
+ fs.rmSync(await resolveSymlinkTarget(getGrokConfigPath()), {
456
+ force: true,
457
+ });
300
458
  }
301
459
  else {
302
460
  await writeFileAtomic(getGrokConfigPath(), remainder);
@@ -63,6 +63,11 @@ export declare function isProxyOwnedValue(args: {
63
63
  written: unknown;
64
64
  current: unknown;
65
65
  }): boolean;
66
+ /**
67
+ * The path a chain of symlinks finally names, which need not exist. A path
68
+ * that is not a link comes back unchanged.
69
+ */
70
+ export declare function resolveSymlinkTarget(filePath: string): Promise<string>;
66
71
  /**
67
72
  * Replace a file's contents without ever exposing a partial one.
68
73
  *
@@ -112,5 +117,12 @@ export declare function isProxyOwnedValue(args: {
112
117
  * is NOT atomic. Moving the temp file to `os.tmpdir()` would look like a
113
118
  * tidy-up and would quietly restore the exact window this function exists to
114
119
  * close, on Windows only, where nobody here would see it.
120
+ *
121
+ * A config kept in a dotfiles repository is often a symlink into it, a setup
122
+ * Grok documents and its own writer honours. A rename over the link would
123
+ * replace the link with a regular file and leave the repository's copy stale,
124
+ * so the write lands on the end of the link chain instead, which need not
125
+ * exist yet. The temp file is that target's sibling, so the rename stays
126
+ * atomic.
115
127
  */
116
128
  export declare function writeFileAtomic(filePath: string, contents: string, mode?: number): Promise<void>;
@@ -113,6 +113,31 @@ export function isProxyOwnedValue(args) {
113
113
  }
114
114
  return valuesMatch(args.current, args.written);
115
115
  }
116
+ /**
117
+ * The path a chain of symlinks finally names, which need not exist. A path
118
+ * that is not a link comes back unchanged.
119
+ */
120
+ export async function resolveSymlinkTarget(filePath) {
121
+ const fs = await import("fs");
122
+ const { dirname, resolve } = await import("path");
123
+ let target = filePath;
124
+ for (let hops = 0;; hops += 1) {
125
+ let isLink;
126
+ try {
127
+ isLink = fs.lstatSync(target).isSymbolicLink();
128
+ }
129
+ catch {
130
+ isLink = false;
131
+ }
132
+ if (!isLink) {
133
+ return target;
134
+ }
135
+ if (hops === 40) {
136
+ throw new Error(`${filePath}: too many levels of symbolic links`);
137
+ }
138
+ target = resolve(dirname(target), fs.readlinkSync(target));
139
+ }
140
+ }
116
141
  /** Distinguishes concurrent writers within one process. */
117
142
  let atomicWriteCounter = 0;
118
143
  /**
@@ -164,12 +189,20 @@ let atomicWriteCounter = 0;
164
189
  * is NOT atomic. Moving the temp file to `os.tmpdir()` would look like a
165
190
  * tidy-up and would quietly restore the exact window this function exists to
166
191
  * close, on Windows only, where nobody here would see it.
192
+ *
193
+ * A config kept in a dotfiles repository is often a symlink into it, a setup
194
+ * Grok documents and its own writer honours. A rename over the link would
195
+ * replace the link with a regular file and leave the repository's copy stale,
196
+ * so the write lands on the end of the link chain instead, which need not
197
+ * exist yet. The temp file is that target's sibling, so the rename stays
198
+ * atomic.
167
199
  */
168
200
  export async function writeFileAtomic(filePath, contents, mode) {
169
201
  const fs = await import("fs");
170
202
  const { dirname, join, basename } = await import("path");
203
+ const target = await resolveSymlinkTarget(filePath);
171
204
  atomicWriteCounter += 1;
172
- const tempPath = join(dirname(filePath), `.${basename(filePath)}.neurolink-${process.pid}-${atomicWriteCounter}.tmp`);
205
+ const tempPath = join(dirname(target), `.${basename(target)}.neurolink-${process.pid}-${atomicWriteCounter}.tmp`);
173
206
  // Which step failed changes what the user should do about it: a failed write
174
207
  // is usually a missing directory or a full disk and the config is untouched,
175
208
  // while a failed rename is a locked destination and the config is intact but
@@ -180,7 +213,7 @@ export async function writeFileAtomic(filePath, contents, mode) {
180
213
  let effectiveMode = mode;
181
214
  if (effectiveMode === undefined) {
182
215
  try {
183
- effectiveMode = fs.statSync(filePath).mode & 0o777;
216
+ effectiveMode = fs.statSync(target).mode & 0o777;
184
217
  }
185
218
  catch {
186
219
  effectiveMode = 0o600;
@@ -192,12 +225,12 @@ export async function writeFileAtomic(filePath, contents, mode) {
192
225
  // caller sees an ENOENT naming a path it never asked to write. Creating
193
226
  // the directory first makes a first-run write behave like the plain
194
227
  // writeFileSync it replaced.
195
- fs.mkdirSync(dirname(filePath), { recursive: true });
228
+ fs.mkdirSync(dirname(target), { recursive: true });
196
229
  fs.writeFileSync(tempPath, contents, { mode: effectiveMode });
197
230
  stage = "chmod";
198
231
  fs.chmodSync(tempPath, effectiveMode);
199
232
  stage = "rename";
200
- fs.renameSync(tempPath, filePath);
233
+ fs.renameSync(tempPath, target);
201
234
  }
202
235
  catch (error) {
203
236
  // Never leave scratch in the user's config directory.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Line-level TOML reading for the proxy's client-config writers.
3
+ *
4
+ * Grok and Codex keep their settings in TOML files the user and the CLI
5
+ * itself also edit, and the writers must find what the proxy wrote there
6
+ * without trusting its comment markers: Grok's own save drops every comment.
7
+ * No TOML parser is a dependency, and a parse-and-serialize round trip would
8
+ * reformat the user's file anyway, so this reads just enough structure to
9
+ * edit text in place: table headers, strings, keys and assignments. Every
10
+ * line keeps its own terminator, so text a writer does not own round-trips
11
+ * byte for byte.
12
+ */
13
+ import type { CliTomlSection } from "../../types/index.js";
14
+ export declare function splitTomlSections(text: string): CliTomlSection[];
15
+ /**
16
+ * A line of TOML with every string replaced by `@<n>@` and any comment
17
+ * dropped, so text inside a string can never read as a key or an assignment.
18
+ * `strings[n]` is the decoded value. `@` cannot occur outside a string in
19
+ * valid TOML.
20
+ */
21
+ export declare function maskTomlStrings(line: string): {
22
+ masked: string;
23
+ strings: string[];
24
+ };
25
+ export declare function parseMaskedAssignment(masked: string, strings: readonly string[]): {
26
+ path: string[];
27
+ value: string;
28
+ } | null;
29
+ /**
30
+ * The string a masked value starts with. Only the start counts, so a value
31
+ * with damage after it (a literal `\n` left by a bad paste) is still read.
32
+ */
33
+ export declare function maskedStringValue(value: string, strings: readonly string[]): string | undefined;
34
+ /** The pairs directly inside a one-line inline table. */
35
+ export declare function inlineTablePairs(value: string, strings: readonly string[]): {
36
+ path: string[];
37
+ value: string;
38
+ }[];
39
+ /** The dotted key a statement assigns, and its value when that is a string. */
40
+ export declare function readTomlAssignment(line: string): {
41
+ path: string[];
42
+ value: string | undefined;
43
+ } | null;
44
+ /**
45
+ * The lines of a section that are TOML rather than the inside of a
46
+ * multi-line string; `statement` marks those that start a key/value pair
47
+ * rather than continue an array or inline table.
48
+ */
49
+ export declare function tomlCodeLines(section: CliTomlSection): {
50
+ line: string;
51
+ statement: boolean;
52
+ }[];
53
+ export declare function readStringValue(section: CliTomlSection, key: string): string | undefined;
54
+ /**
55
+ * Names of the `<parent>.<name>` tables defined by dotted keys instead of a
56
+ * `[<parent>.<name>]` header: `claude-opus-4-6.base_url = ...` under
57
+ * `[model]`, or `model.claude-opus-4-6.base_url = ...` before the first
58
+ * header.
59
+ */
60
+ export declare function dottedTableIds(sections: readonly CliTomlSection[], parent: string): string[];
61
+ /**
62
+ * How many of an owned section's lines are the writer's. Comments after its
63
+ * last key belong to what follows, so a note the user wrote above their own
64
+ * next table survives. `managedLines` are the writer's own comment lines,
65
+ * which are dropped wherever they sit.
66
+ */
67
+ export declare function ownedLineCount(section: CliTomlSection, managedLines: ReadonlySet<string>): number;
68
+ /**
69
+ * A table declared twice is a TOML error, and neither Grok nor Codex will
70
+ * start on one.
71
+ * Each `[[array]]` element opens a fresh scope for everything under it, so
72
+ * `[mcp_servers.env]` under two elements is not a repeat.
73
+ */
74
+ export declare function findDuplicateTable(text: string): string | undefined;
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Line-level TOML reading for the proxy's client-config writers.
3
+ *
4
+ * Grok and Codex keep their settings in TOML files the user and the CLI
5
+ * itself also edit, and the writers must find what the proxy wrote there
6
+ * without trusting its comment markers: Grok's own save drops every comment.
7
+ * No TOML parser is a dependency, and a parse-and-serialize round trip would
8
+ * reformat the user's file anyway, so this reads just enough structure to
9
+ * edit text in place: table headers, strings, keys and assignments. Every
10
+ * line keeps its own terminator, so text a writer does not own round-trips
11
+ * byte for byte.
12
+ */
13
+ function splitLinesKeepingEnds(text) {
14
+ return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
15
+ }
16
+ function readTomlKey(text) {
17
+ const bare = text.match(/^[A-Za-z0-9_-]+/);
18
+ if (bare) {
19
+ return { value: bare[0], length: bare[0].length };
20
+ }
21
+ const basic = text.match(/^"(?:[^"\\]|\\.)*"/);
22
+ if (basic) {
23
+ let value;
24
+ try {
25
+ value = JSON.parse(basic[0]);
26
+ }
27
+ catch {
28
+ value = undefined;
29
+ }
30
+ return {
31
+ value: typeof value === "string" ? value : basic[0].slice(1, -1),
32
+ length: basic[0].length,
33
+ };
34
+ }
35
+ const literal = text.match(/^'[^']*'/);
36
+ if (literal) {
37
+ return { value: literal[0].slice(1, -1), length: literal[0].length };
38
+ }
39
+ return null;
40
+ }
41
+ function parseTomlTableHeader(line) {
42
+ const text = line.trim();
43
+ if (!text.startsWith("[")) {
44
+ return null;
45
+ }
46
+ const arrayTable = text.startsWith("[[");
47
+ const path = [];
48
+ let rest = text.slice(arrayTable ? 2 : 1);
49
+ for (;;) {
50
+ rest = rest.trimStart();
51
+ const key = readTomlKey(rest);
52
+ if (key === null) {
53
+ return null;
54
+ }
55
+ path.push(key.value);
56
+ rest = rest.slice(key.length).trimStart();
57
+ if (!rest.startsWith(".")) {
58
+ break;
59
+ }
60
+ rest = rest.slice(1);
61
+ }
62
+ const close = arrayTable ? "]]" : "]";
63
+ if (!rest.startsWith(close)) {
64
+ return null;
65
+ }
66
+ const tail = rest.slice(close.length).trim();
67
+ return tail.length === 0 || tail.startsWith("#")
68
+ ? { path, arrayTable }
69
+ : null;
70
+ }
71
+ function advanceTomlScan(line, state) {
72
+ let { multiline, depth } = state;
73
+ let i = 0;
74
+ while (i < line.length) {
75
+ if (multiline !== null) {
76
+ if (multiline === '"""' && line[i] === "\\") {
77
+ i += 2;
78
+ }
79
+ else if (line.startsWith(multiline, i)) {
80
+ // Up to two quotes of content may sit against the closing delimiter.
81
+ const quote = multiline[0];
82
+ i += 3;
83
+ while (line[i] === quote) {
84
+ i += 1;
85
+ }
86
+ multiline = null;
87
+ }
88
+ else {
89
+ i += 1;
90
+ }
91
+ continue;
92
+ }
93
+ const ch = line[i];
94
+ if (ch === "#") {
95
+ break;
96
+ }
97
+ if (line.startsWith('"""', i) || line.startsWith("'''", i)) {
98
+ multiline = line.startsWith('"""', i) ? '"""' : "'''";
99
+ i += 3;
100
+ }
101
+ else if (ch === '"') {
102
+ i += 1;
103
+ while (i < line.length && line[i] !== '"') {
104
+ i += line[i] === "\\" ? 2 : 1;
105
+ }
106
+ i += 1;
107
+ }
108
+ else if (ch === "'") {
109
+ const end = line.indexOf("'", i + 1);
110
+ i = end === -1 ? line.length : end + 1;
111
+ }
112
+ else {
113
+ if (ch === "[" || ch === "{") {
114
+ depth += 1;
115
+ }
116
+ else if (ch === "]" || ch === "}") {
117
+ depth = Math.max(0, depth - 1);
118
+ }
119
+ i += 1;
120
+ }
121
+ }
122
+ return { multiline, depth };
123
+ }
124
+ export function splitTomlSections(text) {
125
+ const sections = [];
126
+ let current = { path: null, arrayTable: false, lines: [] };
127
+ let state = { multiline: null, depth: 0 };
128
+ for (const line of splitLinesKeepingEnds(text)) {
129
+ const header = state.multiline === null && state.depth === 0
130
+ ? parseTomlTableHeader(line)
131
+ : null;
132
+ if (header) {
133
+ sections.push(current);
134
+ current = { ...header, lines: [line] };
135
+ continue;
136
+ }
137
+ current.lines.push(line);
138
+ state = advanceTomlScan(line, state);
139
+ }
140
+ sections.push(current);
141
+ return sections;
142
+ }
143
+ function decodeBasicString(inner) {
144
+ try {
145
+ const value = JSON.parse(`"${inner}"`);
146
+ return typeof value === "string" ? value : inner;
147
+ }
148
+ catch {
149
+ return inner;
150
+ }
151
+ }
152
+ /**
153
+ * A line of TOML with every string replaced by `@<n>@` and any comment
154
+ * dropped, so text inside a string can never read as a key or an assignment.
155
+ * `strings[n]` is the decoded value. `@` cannot occur outside a string in
156
+ * valid TOML.
157
+ */
158
+ export function maskTomlStrings(line) {
159
+ const strings = [];
160
+ let masked = "";
161
+ let i = 0;
162
+ while (i < line.length) {
163
+ const ch = line[i];
164
+ if (ch === "#") {
165
+ break;
166
+ }
167
+ const triple = line.startsWith('"""', i)
168
+ ? '"""'
169
+ : line.startsWith("'''", i)
170
+ ? "'''"
171
+ : null;
172
+ if (triple !== null || ch === '"' || ch === "'") {
173
+ const delimiter = triple ?? ch;
174
+ let end = i + delimiter.length;
175
+ while (end < line.length && !line.startsWith(delimiter, end)) {
176
+ end += delimiter.startsWith('"') && line[end] === "\\" ? 2 : 1;
177
+ }
178
+ // Up to two quotes of content may sit against a triple closer.
179
+ for (let extra = 0; triple !== null &&
180
+ extra < 2 &&
181
+ line[end + delimiter.length] === delimiter[0]; extra += 1) {
182
+ end += 1;
183
+ }
184
+ const inner = line.slice(i + delimiter.length, Math.min(end, line.length));
185
+ strings.push(delimiter.startsWith("'") ? inner : decodeBasicString(inner));
186
+ masked += `@${strings.length - 1}@`;
187
+ i = end + delimiter.length;
188
+ continue;
189
+ }
190
+ masked += ch;
191
+ i += 1;
192
+ }
193
+ return { masked, strings };
194
+ }
195
+ const TOML_KEY_PART = String.raw `(?:[A-Za-z0-9_-]+|@\d+@)`;
196
+ const TOML_ASSIGNMENT = new RegExp(String.raw `^\s*(${TOML_KEY_PART}(?:\s*\.\s*${TOML_KEY_PART})*)\s*=\s*(.*?)\s*$`);
197
+ function unmaskKeyPart(part, strings) {
198
+ const token = part.match(/^@(\d+)@$/);
199
+ return token ? strings[Number(token[1])] : part;
200
+ }
201
+ export function parseMaskedAssignment(masked, strings) {
202
+ const match = masked.match(TOML_ASSIGNMENT);
203
+ return match
204
+ ? {
205
+ path: match[1]
206
+ .split(".")
207
+ .map((part) => unmaskKeyPart(part.trim(), strings)),
208
+ value: match[2],
209
+ }
210
+ : null;
211
+ }
212
+ /**
213
+ * The string a masked value starts with. Only the start counts, so a value
214
+ * with damage after it (a literal `\n` left by a bad paste) is still read.
215
+ */
216
+ export function maskedStringValue(value, strings) {
217
+ const token = value.match(/^@(\d+)@/);
218
+ return token ? strings[Number(token[1])] : undefined;
219
+ }
220
+ /** The pairs directly inside a one-line inline table. */
221
+ export function inlineTablePairs(value, strings) {
222
+ if (!value.startsWith("{") || !value.endsWith("}")) {
223
+ return [];
224
+ }
225
+ const parts = [];
226
+ let depth = 0;
227
+ let start = 1;
228
+ for (let i = 1; i < value.length - 1; i += 1) {
229
+ if (value[i] === "{" || value[i] === "[") {
230
+ depth += 1;
231
+ }
232
+ else if (value[i] === "}" || value[i] === "]") {
233
+ depth -= 1;
234
+ }
235
+ else if (value[i] === "," && depth === 0) {
236
+ parts.push(value.slice(start, i));
237
+ start = i + 1;
238
+ }
239
+ }
240
+ parts.push(value.slice(start, value.length - 1));
241
+ return parts.flatMap((part) => {
242
+ const pair = parseMaskedAssignment(part, strings);
243
+ return pair ? [pair] : [];
244
+ });
245
+ }
246
+ /** The dotted key a statement assigns, and its value when that is a string. */
247
+ export function readTomlAssignment(line) {
248
+ const { masked, strings } = maskTomlStrings(line);
249
+ const assignment = parseMaskedAssignment(masked, strings);
250
+ return assignment
251
+ ? {
252
+ path: assignment.path,
253
+ value: maskedStringValue(assignment.value, strings),
254
+ }
255
+ : null;
256
+ }
257
+ function isTomlContentLine(line) {
258
+ const text = line.trim();
259
+ return text.length > 0 && !text.startsWith("#");
260
+ }
261
+ /**
262
+ * The lines of a section that are TOML rather than the inside of a
263
+ * multi-line string; `statement` marks those that start a key/value pair
264
+ * rather than continue an array or inline table.
265
+ */
266
+ export function tomlCodeLines(section) {
267
+ const code = [];
268
+ let state = { multiline: null, depth: 0 };
269
+ section.lines.forEach((line, index) => {
270
+ const isHeader = index === 0 && section.path !== null;
271
+ if (!isHeader && state.multiline === null) {
272
+ code.push({ line, statement: state.depth === 0 });
273
+ }
274
+ if (!isHeader) {
275
+ state = advanceTomlScan(line, state);
276
+ }
277
+ });
278
+ return code;
279
+ }
280
+ export function readStringValue(section, key) {
281
+ for (const { line, statement } of tomlCodeLines(section)) {
282
+ const assignment = statement ? readTomlAssignment(line) : null;
283
+ if (assignment?.path.length === 1 && assignment.path[0] === key) {
284
+ return assignment.value;
285
+ }
286
+ }
287
+ return undefined;
288
+ }
289
+ /**
290
+ * Names of the `<parent>.<name>` tables defined by dotted keys instead of a
291
+ * `[<parent>.<name>]` header: `claude-opus-4-6.base_url = ...` under
292
+ * `[model]`, or `model.claude-opus-4-6.base_url = ...` before the first
293
+ * header.
294
+ */
295
+ export function dottedTableIds(sections, parent) {
296
+ return sections.flatMap((section) => {
297
+ const underParent = !section.arrayTable &&
298
+ section.path?.length === 1 &&
299
+ section.path[0] === parent;
300
+ if (!underParent && section.path !== null) {
301
+ return [];
302
+ }
303
+ return tomlCodeLines(section).flatMap(({ line, statement }) => {
304
+ const path = statement ? readTomlAssignment(line)?.path : undefined;
305
+ if (!path) {
306
+ return [];
307
+ }
308
+ if (underParent) {
309
+ return [path[0]];
310
+ }
311
+ return path.length >= 2 && path[0] === parent ? [path[1]] : [];
312
+ });
313
+ });
314
+ }
315
+ /**
316
+ * How many of an owned section's lines are the writer's. Comments after its
317
+ * last key belong to what follows, so a note the user wrote above their own
318
+ * next table survives. `managedLines` are the writer's own comment lines,
319
+ * which are dropped wherever they sit.
320
+ */
321
+ export function ownedLineCount(section, managedLines) {
322
+ const lastKey = section.lines.findLastIndex(isTomlContentLine);
323
+ const firstNote = section.lines.findIndex((line, index) => index > lastKey &&
324
+ line.trim().length > 0 &&
325
+ !managedLines.has(line.trim()));
326
+ return firstNote === -1 ? section.lines.length : firstNote;
327
+ }
328
+ /**
329
+ * A table declared twice is a TOML error, and neither Grok nor Codex will
330
+ * start on one.
331
+ * Each `[[array]]` element opens a fresh scope for everything under it, so
332
+ * `[mcp_servers.env]` under two elements is not a repeat.
333
+ */
334
+ export function findDuplicateTable(text) {
335
+ const tables = new Map();
336
+ const arrays = new Map();
337
+ const isUnder = (candidate, prefix) => candidate.length > prefix.length &&
338
+ prefix.every((part, index) => candidate[index] === part);
339
+ for (const { path, arrayTable } of splitTomlSections(text)) {
340
+ if (path === null) {
341
+ continue;
342
+ }
343
+ const key = JSON.stringify(path);
344
+ if (arrayTable) {
345
+ if (tables.has(key)) {
346
+ return path.join(".");
347
+ }
348
+ for (const scope of [tables, arrays]) {
349
+ for (const [seenKey, seenPath] of scope) {
350
+ if (isUnder(seenPath, path)) {
351
+ scope.delete(seenKey);
352
+ }
353
+ }
354
+ }
355
+ arrays.set(key, path);
356
+ continue;
357
+ }
358
+ if (tables.has(key) || arrays.has(key)) {
359
+ return path.join(".");
360
+ }
361
+ tables.set(key, path);
362
+ }
363
+ return undefined;
364
+ }
365
+ //# sourceMappingURL=tomlSections.js.map
@@ -29,8 +29,10 @@ export type CliProxyClientConfigurator = {
29
29
  /**
30
30
  * Point the CLI at the proxy. `proxyBaseUrl` is the bare proxy origin
31
31
  * (e.g. "http://127.0.0.1:55669"); the configurator appends whatever path
32
- * suffix its CLI needs. Returns false when nothing was written, so callers
33
- * never print a success message for work that did not happen.
32
+ * suffix its CLI needs. Returns false when the CLI is not left pointing at
33
+ * the proxy, so callers never print a success message for work that did
34
+ * not happen. A configurator whose file already matches may return true
35
+ * without rewriting it.
34
36
  */
35
37
  apply: (proxyBaseUrl: string, options?: CliProxyClientApplyOptions) => Promise<boolean>;
36
38
  /**
@@ -55,7 +57,10 @@ export type CliProxyClientConfigurator = {
55
57
  export type CliProxyClientApplyResult = {
56
58
  id: string;
57
59
  displayName: string;
58
- /** True only when the configurator actually wrote configuration. */
60
+ /**
61
+ * True only when the CLI's configuration points at the proxy: written now,
62
+ * or already written and left as it was.
63
+ */
59
64
  applied: boolean;
60
65
  /**
61
66
  * Set when the write landed but is not yet in effect — see
@@ -110,6 +115,26 @@ export type CliGrokSnapshot = {
110
115
  originalExisted: boolean;
111
116
  writtenBaseUrl: string;
112
117
  };
118
+ /**
119
+ * One header-delimited run of a client's TOML config. `path` is the parsed
120
+ * header key (`["model", "gemini-2.5-pro"]` for `[model."gemini-2.5-pro"]`),
121
+ * or null for the keys before the first header. `lines` keep their own
122
+ * terminators, so text the writer does not own round-trips byte for byte.
123
+ */
124
+ export type CliTomlSection = {
125
+ path: readonly string[] | null;
126
+ arrayTable: boolean;
127
+ lines: readonly string[];
128
+ };
129
+ /**
130
+ * What a line of TOML leaves open for the next one: a multi-line string, or
131
+ * brackets of an array or inline table. A `[` that starts a line is a table
132
+ * header only when neither is open.
133
+ */
134
+ export type CliTomlScanState = {
135
+ multiline: '"""' | "'''" | null;
136
+ depth: number;
137
+ };
113
138
  /**
114
139
  * Snapshot of the user's pre-existing Gemini CLI `~/.gemini/.env`.
115
140
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.30.0",
3
+ "version": "12.30.1",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {