@bli-cockpit/cli 0.2.255 → 0.2.256

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,192 @@
1
+ /**
2
+ * Finds the first key a TOML file defines twice (BLI-22766).
3
+ *
4
+ * Why this exists: `codex` refuses to start when `~/.codex/config.toml` defines
5
+ * one key twice — for example `[mcp_servers.bli-memory]` holding an inline
6
+ * `env = { … }` AND a separate `[mcp_servers.bli-memory.env]` table. It prints
7
+ * `config.toml:180:25: duplicate key` and does nothing else, so every Codex
8
+ * worker on that machine dies silently. `cockpit doctor` needs to say so, by
9
+ * file and line.
10
+ *
11
+ * This is NOT a TOML parser, for the same reason `memory-install-toml.ts` is
12
+ * not one: the public CLI ships no TOML dependency. It models only what makes a
13
+ * key "defined": table headers, array-of-table headers, `key = value` lines
14
+ * (dotted keys included) and inline tables. It skips over multi-line strings
15
+ * and multi-line arrays so their contents are never mistaken for keys. The
16
+ * tests check its answer against a real TOML parser on the same fixtures.
17
+ *
18
+ * What it does NOT catch: syntax errors that are not duplicate keys (a missing
19
+ * quote, a bad date). Those still break Codex; this check reports only the
20
+ * class of breakage our own writer could cause.
21
+ */
22
+ export function findTomlDuplicateKey(text) {
23
+ const values = new Set();
24
+ const tables = new Set();
25
+ const dottedTables = new Set();
26
+ const arrayTables = new Map();
27
+ let current = [];
28
+ let pending = null;
29
+ const lines = text.split(/\r?\n/u);
30
+ for (let index = 0; index < lines.length; index += 1) {
31
+ const line = lines[index] ?? "";
32
+ if (pending) {
33
+ pending = continuePending(line, pending);
34
+ continue;
35
+ }
36
+ const trimmed = stripComment(line).trim();
37
+ if (!trimmed)
38
+ continue;
39
+ if (trimmed.startsWith("[[")) {
40
+ const raw = trimmed.slice(2, trimmed.lastIndexOf("]]"));
41
+ const resolved = resolveArrayPrefixes(splitKeyPath(raw), arrayTables, false);
42
+ const id = encode(resolved);
43
+ const count = (arrayTables.get(id) ?? 0) + 1;
44
+ arrayTables.set(id, count);
45
+ current = [...resolved, `#${count}`];
46
+ continue;
47
+ }
48
+ if (trimmed.startsWith("[")) {
49
+ const resolved = resolveArrayPrefixes(splitKeyPath(trimmed.slice(1, trimmed.lastIndexOf("]"))), arrayTables, true);
50
+ const id = encode(resolved);
51
+ if (tables.has(id) || dottedTables.has(id) || hasValuePrefix(values, resolved)) {
52
+ return duplicate(index, resolved);
53
+ }
54
+ tables.add(id);
55
+ current = resolved;
56
+ continue;
57
+ }
58
+ const equals = indexOutsideQuotes(trimmed, "=");
59
+ if (equals <= 0)
60
+ continue;
61
+ const full = [...current, ...splitKeyPath(trimmed.slice(0, equals))];
62
+ const id = encode(full);
63
+ if (hasValuePrefix(values, full) || tables.has(id) || dottedTables.has(id)) {
64
+ return duplicate(index, full);
65
+ }
66
+ if (hasTableUnder(tables, full))
67
+ return duplicate(index, full);
68
+ values.add(id);
69
+ for (let length = current.length + 1; length < full.length; length += 1) {
70
+ dottedTables.add(encode(full.slice(0, length)));
71
+ }
72
+ pending = openPending(trimmed.slice(equals + 1).trim());
73
+ }
74
+ return null;
75
+ }
76
+ function duplicate(index, path) {
77
+ return { line: index + 1, key: path.filter((part) => !part.startsWith("#")).join(".") };
78
+ }
79
+ function encode(path) {
80
+ return JSON.stringify(path);
81
+ }
82
+ function hasValuePrefix(values, path) {
83
+ for (let length = 1; length <= path.length; length += 1) {
84
+ if (values.has(encode(path.slice(0, length))))
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+ function hasTableUnder(tables, path) {
90
+ const prefix = encode(path).slice(0, -1);
91
+ for (const table of tables) {
92
+ if (table.startsWith(`${prefix},`))
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+ /** `[a.b]` after `[[a]]` means "b inside the LAST a" — give it that element's path. */
98
+ function resolveArrayPrefixes(path, arrayTables, includeLast) {
99
+ const resolved = [];
100
+ path.forEach((part, index) => {
101
+ resolved.push(part);
102
+ if (!includeLast && index === path.length - 1)
103
+ return;
104
+ const count = arrayTables.get(encode(resolved));
105
+ if (count !== undefined)
106
+ resolved.push(`#${count}`);
107
+ });
108
+ return resolved;
109
+ }
110
+ /** Decides whether a value continues onto later lines. */
111
+ function openPending(value) {
112
+ for (const closer of ['"""', "'''"]) {
113
+ if (value.startsWith(closer)) {
114
+ return value.indexOf(closer, closer.length) >= 0 ? null : { kind: "string", closer };
115
+ }
116
+ }
117
+ if (value.startsWith("[") || value.startsWith("{")) {
118
+ const depth = bracketDepth(value, 0);
119
+ return depth > 0 ? { kind: "brackets", depth } : null;
120
+ }
121
+ return null;
122
+ }
123
+ function continuePending(line, pending) {
124
+ if (pending.kind === "string")
125
+ return line.includes(pending.closer) ? null : pending;
126
+ const depth = bracketDepth(line, pending.depth);
127
+ return depth > 0 ? { kind: "brackets", depth } : null;
128
+ }
129
+ function bracketDepth(raw, start) {
130
+ let depth = start;
131
+ let quote = null;
132
+ for (const character of raw) {
133
+ if (quote) {
134
+ if (character === quote)
135
+ quote = null;
136
+ continue;
137
+ }
138
+ if (character === '"' || character === "'")
139
+ quote = character;
140
+ else if (character === "#")
141
+ break;
142
+ else if (character === "[" || character === "{")
143
+ depth += 1;
144
+ else if (character === "]" || character === "}")
145
+ depth -= 1;
146
+ }
147
+ return depth;
148
+ }
149
+ function indexOutsideQuotes(raw, target) {
150
+ let quote = null;
151
+ for (let index = 0; index < raw.length; index += 1) {
152
+ const character = raw[index];
153
+ if (quote) {
154
+ if (character === quote)
155
+ quote = null;
156
+ continue;
157
+ }
158
+ if (character === '"' || character === "'")
159
+ quote = character;
160
+ else if (character === target)
161
+ return index;
162
+ }
163
+ return -1;
164
+ }
165
+ function stripComment(raw) {
166
+ const index = indexOutsideQuotes(raw, "#");
167
+ return index < 0 ? raw : raw.slice(0, index);
168
+ }
169
+ function splitKeyPath(raw) {
170
+ const parts = [];
171
+ let current = "";
172
+ let quote = null;
173
+ for (const character of raw) {
174
+ if (quote) {
175
+ if (character === quote)
176
+ quote = null;
177
+ else
178
+ current += character;
179
+ continue;
180
+ }
181
+ if (character === '"' || character === "'")
182
+ quote = character;
183
+ else if (character === ".") {
184
+ parts.push(current.trim());
185
+ current = "";
186
+ }
187
+ else
188
+ current += character;
189
+ }
190
+ parts.push(current.trim());
191
+ return parts;
192
+ }
@@ -12,46 +12,25 @@
12
12
  */
13
13
  import path from "node:path";
14
14
  import { TOWER_MCP_SERVER_ID } from "./tower-mcp-contract.js";
15
- import { findTomlTableSpan, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
15
+ import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
16
+ import { findTomlTableSpan } from "./memory-install-toml.js";
17
+ import { applyCodexServerTable, codexConfigUnparseable, codexServerTableMatches, } from "./codex-mcp-table.js";
16
18
  const TOWER_TOML_PATH = ["mcp_servers", TOWER_MCP_SERVER_ID];
19
+ /** BLI Memory's writer runs first on the same command and repairs its own table. */
20
+ const SIBLING_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
17
21
  export function codexConfigFile(homeDir) {
18
22
  return path.join(homeDir, ".codex", "config.toml");
19
23
  }
20
24
  export async function installCodexTowerIntegration(options) {
21
- const file = codexConfigFile(options.homeDir);
22
- const raw = (await options.io.readText(file)) ?? "";
23
- if (tableMatches(raw, options.config)) {
24
- return { target: "tower_codex_mcp", status: "already", reason: "already_current", path: file };
25
- }
26
- const next = upsertTomlTable(raw, TOWER_TOML_PATH, renderTowerTable(options.config), "tower-mcp");
27
- if (options.dryRun) {
28
- return { target: "tower_codex_mcp", status: "would_install", reason: "dry_run", path: file };
29
- }
30
- try {
31
- await options.io.writeText(file, next);
32
- }
33
- catch (error) {
34
- return {
35
- target: "tower_codex_mcp",
36
- status: "failed",
37
- reason: "write_failed",
38
- path: file,
39
- detail: error instanceof Error ? error.message : String(error),
40
- };
41
- }
42
- // BLI-2541: parse the table back out of the stored bytes, same discipline
43
- // as every other target this installer writes.
44
- const stored = await options.io.readText(file);
45
- if (stored === null || !tableMatches(stored, options.config)) {
46
- return {
47
- target: "tower_codex_mcp",
48
- status: "failed",
49
- reason: "read_back_mismatch",
50
- path: file,
51
- detail: "the table on disk does not parse back to the entry that was written",
52
- };
53
- }
54
- return { target: "tower_codex_mcp", status: "installed", reason: "wrote_entry", path: file };
25
+ return applyCodexServerTable({
26
+ file: codexConfigFile(options.homeDir),
27
+ target: "tower_codex_mcp",
28
+ tablePath: TOWER_TOML_PATH,
29
+ siblingPath: SIBLING_TOML_PATH,
30
+ config: options.config,
31
+ dryRun: options.dryRun,
32
+ io: options.io,
33
+ });
55
34
  }
56
35
  export async function inspectCodexTowerIntegration(options) {
57
36
  const file = codexConfigFile(options.homeDir);
@@ -59,42 +38,14 @@ export async function inspectCodexTowerIntegration(options) {
59
38
  if (raw === null) {
60
39
  return { target: "tower_codex_mcp", status: "missing", reason: "file_absent", path: file };
61
40
  }
62
- if (tableMatches(raw, options.config)) {
41
+ // BLI-22766: a file Codex cannot load is named first, whatever our table says.
42
+ const broken = codexConfigUnparseable(raw, file, "tower_codex_mcp");
43
+ if (broken)
44
+ return broken;
45
+ if (codexServerTableMatches(raw, TOWER_TOML_PATH, options.config)) {
63
46
  return { target: "tower_codex_mcp", status: "installed", reason: "already_current", path: file };
64
47
  }
65
48
  return findTomlTableSpan(raw, TOWER_TOML_PATH)
66
49
  ? { target: "tower_codex_mcp", status: "mismatch", reason: "entry_differs", path: file }
67
50
  : { target: "tower_codex_mcp", status: "missing", reason: "entry_absent", path: file };
68
- }
69
- function renderTowerTable(config) {
70
- const entries = [
71
- ["command", config.mcp_server.command],
72
- ["args", config.mcp_server.args],
73
- ];
74
- if (Object.keys(config.mcp_server.env).length > 0) {
75
- entries.push(["env", config.mcp_server.env]);
76
- }
77
- return renderTomlTable(TOWER_TOML_PATH, entries);
78
- }
79
- function tableMatches(raw, config) {
80
- const table = readTomlTable(raw, TOWER_TOML_PATH);
81
- if (!table)
82
- return false;
83
- if (table["command"] !== config.mcp_server.command)
84
- return false;
85
- const args = table["args"];
86
- if (!Array.isArray(args))
87
- return config.mcp_server.args.length === 0;
88
- if (args.length !== config.mcp_server.args.length)
89
- return false;
90
- if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
91
- return false;
92
- const env = table["env"];
93
- for (const [key, value] of Object.entries(config.mcp_server.env)) {
94
- if (!env || Array.isArray(env) || typeof env === "string")
95
- return false;
96
- if (env[key] !== value)
97
- return false;
98
- }
99
- return true;
100
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.255",
3
+ "version": "0.2.256",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.75",
31
- "@bli-cockpit/mcp": "0.1.156",
32
- "@bli-cockpit/telemetry-core": "0.1.82"
30
+ "@bli-cockpit/memory-mcp": "0.1.76",
31
+ "@bli-cockpit/mcp": "0.1.157",
32
+ "@bli-cockpit/telemetry-core": "0.1.83"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",