@bli-cockpit/cli 0.2.49 → 0.2.50

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.
Files changed (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +231 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +378 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The interface this installer consumes from `@bli-cockpit/memory-mcp`
3
+ * (BLI-3580, core slice on branch `edward/bli-3580-agent-memory-core`).
4
+ *
5
+ * That package does not exist on `main` yet, and the two pull requests must be
6
+ * able to land in either order — so the contract lives HERE, in one small
7
+ * module, and is stated twice on purpose:
8
+ *
9
+ * 1. `bli-memory-mcp --print-config --claude` prints the JSON below, and
10
+ * `--print-config --codex` prints the TOML table below. When the bin
11
+ * resolves, what it prints WINS: the server knows its own shape.
12
+ * 2. `builtinMemoryInstallConfig()` is the same shape, built locally, for a
13
+ * server that resolved but does not answer `--print-config` yet.
14
+ *
15
+ * Both require a RESOLVED bin. A machine that does not have the server gets
16
+ * nothing written at all and a `skipped bin_missing` receipt — the template is
17
+ * a fallback for an older server, never a substitute for an absent one
18
+ * (`no_bin_no_write`, memory-install.ts).
19
+ *
20
+ * The printed JSON contract (`--print-config --claude`):
21
+ *
22
+ * ```json
23
+ * {
24
+ * "schema": "bli-memory-install-config.v1",
25
+ * "server_id": "bli-memory",
26
+ * "mcp_server": { "command": "bli-memory-mcp", "args": [], "env": {} },
27
+ * "hooks": [
28
+ * { "event": "SessionStart", "command": "bli-memory-mcp hook session-start", "timeout_seconds": 30 },
29
+ * { "event": "UserPromptSubmit", "command": "bli-memory-mcp hook prompt", "timeout_seconds": 5 },
30
+ * { "event": "Stop", "command": "bli-memory-mcp hook stop", "timeout_seconds": 30 }
31
+ * ],
32
+ * "permissions_allow": ["mcp__bli-memory__search_memory"]
33
+ * }
34
+ * ```
35
+ *
36
+ * The printed TOML contract (`--print-config --codex`) is exactly one table:
37
+ *
38
+ * ```toml
39
+ * [mcp_servers.bli-memory]
40
+ * command = "bli-memory-mcp"
41
+ * args = []
42
+ * env = { COCKPIT_DASHBOARD_URL = "https://…" }
43
+ * ```
44
+ *
45
+ * Nothing here touches the filesystem. The halves that do are
46
+ * `memory-install-claude.ts` and `memory-install-codex.ts`.
47
+ */
48
+ /** The published bin name. Both hosts spawn this. */
49
+ export const MEMORY_MCP_BIN = "bli-memory-mcp";
50
+ /** The MCP server id, as it appears in `mcp__<server>__<tool>`. */
51
+ export const MEMORY_MCP_SERVER_ID = "bli-memory";
52
+ /** The one env var the server is handed. A URL, never a token. */
53
+ export const MEMORY_DASHBOARD_URL_ENV = "COCKPIT_DASHBOARD_URL";
54
+ /** Claude Code hook events this installer registers, in the order it writes them. */
55
+ export const MEMORY_HOOK_EVENTS = [
56
+ "SessionStart",
57
+ "UserPromptSubmit",
58
+ "Stop",
59
+ ];
60
+ /**
61
+ * The bin's own hook subcommands. `session-start` recalls at session open,
62
+ * `prompt` recalls per turn, `stop` saves the turn — the same three jobs the
63
+ * Supermemory plugin's `session-start.js` / `recall-directive.js` / `capture.js`
64
+ * did, collapsed onto one bin.
65
+ */
66
+ export const MEMORY_HOOK_SUBCOMMAND = {
67
+ SessionStart: "hook session-start",
68
+ UserPromptSubmit: "hook prompt",
69
+ Stop: "hook stop",
70
+ };
71
+ /**
72
+ * Injection hooks must stay fast — Claude Code discards a hook that outlives
73
+ * its timeout, and a recall that arrives late is a recall that did not happen.
74
+ * These mirror the vendor plugin's own budgets (30 s / 5 s / 30 s).
75
+ */
76
+ export const MEMORY_HOOK_TIMEOUT_SECONDS = {
77
+ SessionStart: 30,
78
+ UserPromptSubmit: 5,
79
+ Stop: 30,
80
+ };
81
+ /**
82
+ * Auto-approved tools. READ ONLY, deliberately: the vendor's
83
+ * `recall-approve.js` allowed its read set and left every write prompting, and
84
+ * a memory the agent can silently overwrite is worse than one it has to ask
85
+ * about. `save_memory` / `update_memory` / `forget_memory` are absent on purpose.
86
+ */
87
+ export const MEMORY_AUTO_APPROVE_TOOLS = [
88
+ `mcp__${MEMORY_MCP_SERVER_ID}__search_memory`,
89
+ ];
90
+ /** Characters that would let a resolved path change the meaning of a hook command string. */
91
+ const UNSAFE_PATH_CHARACTERS = /["'`$;&|<>\r\n]/u;
92
+ export function isUnsafeBinPath(binPath) {
93
+ return UNSAFE_PATH_CHARACTERS.test(binPath);
94
+ }
95
+ /**
96
+ * A Claude Code hook `command` is a shell string by the platform's design, so
97
+ * the only defence is quoting — and a path that could not be quoted safely is
98
+ * refused upstream (see `resolveMemoryMcpBin`) rather than escaped cleverly.
99
+ */
100
+ export function shellQuoteBinPath(binPath) {
101
+ return /\s/u.test(binPath) ? `"${binPath}"` : binPath;
102
+ }
103
+ /**
104
+ * Windows cannot spawn an npm `.cmd` shim directly — Node refuses it without a
105
+ * shell, which is why `commands/editor.ts` REFUSES a `.cmd` editor rather than
106
+ * running it through cmd.exe with a person's path interpolated in. Here the
107
+ * path is ours, not a person's, and both hosts spawn the MCP server themselves
108
+ * with an argument array, so `cmd /c <shim>` is the correct and safe shape: the
109
+ * arguments never become one string we built.
110
+ */
111
+ export function memoryMcpServerEntry(options) {
112
+ const env = options.dashboardUrl
113
+ ? { [MEMORY_DASHBOARD_URL_ENV]: options.dashboardUrl }
114
+ : {};
115
+ if (options.platform === "win32") {
116
+ return { command: "cmd", args: ["/c", options.binPath], env };
117
+ }
118
+ return { command: options.binPath, args: [], env };
119
+ }
120
+ export function builtinMemoryInstallConfig(options) {
121
+ const quoted = shellQuoteBinPath(options.binPath);
122
+ return {
123
+ server_id: MEMORY_MCP_SERVER_ID,
124
+ mcp_server: memoryMcpServerEntry(options),
125
+ hooks: MEMORY_HOOK_EVENTS.map((event) => ({
126
+ event,
127
+ command: `${quoted} ${MEMORY_HOOK_SUBCOMMAND[event]}`,
128
+ timeout_seconds: MEMORY_HOOK_TIMEOUT_SECONDS[event],
129
+ })),
130
+ permissions_allow: [...MEMORY_AUTO_APPROVE_TOOLS],
131
+ };
132
+ }
133
+ /**
134
+ * Recognises a hook entry as OURS, wherever the bin ended up and however it was
135
+ * quoted. Identity is the bin name followed by `hook`, so an absolute path, a
136
+ * `.cmd` shim and a bare name all match — and somebody else's hook never does.
137
+ * This is what makes a re-run replace our three entries instead of appending a
138
+ * fourth copy.
139
+ */
140
+ export function isMemoryHookCommand(command) {
141
+ return new RegExp(`${escapeRegExp(MEMORY_MCP_BIN)}(?:\\.cmd|\\.exe|\\.bat)?["']?\\s+hook\\b`, "u").test(command);
142
+ }
143
+ function escapeRegExp(value) {
144
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
145
+ }
146
+ /**
147
+ * Parses what `bli-memory-mcp --print-config --claude` printed. Anything that
148
+ * is not the documented shape returns null and the caller falls back to the
149
+ * built-in template with a named reason — a half-understood config is worse
150
+ * than the one we can write ourselves.
151
+ */
152
+ export function parsePrintedMemoryInstallConfig(stdout) {
153
+ let parsed;
154
+ try {
155
+ parsed = JSON.parse(stdout.trim());
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ if (!parsed || typeof parsed !== "object")
161
+ return null;
162
+ const record = parsed;
163
+ const server = record["mcp_server"];
164
+ if (!server || typeof server !== "object")
165
+ return null;
166
+ const serverRecord = server;
167
+ const command = serverRecord["command"];
168
+ if (typeof command !== "string" || !command.trim())
169
+ return null;
170
+ const args = normalizeStringArray(serverRecord["args"]);
171
+ const env = normalizeStringRecord(serverRecord["env"]);
172
+ if (args === null || env === null)
173
+ return null;
174
+ const rawHooks = record["hooks"];
175
+ if (!Array.isArray(rawHooks))
176
+ return null;
177
+ const hooks = [];
178
+ for (const rawHook of rawHooks) {
179
+ if (!rawHook || typeof rawHook !== "object")
180
+ return null;
181
+ const hookRecord = rawHook;
182
+ const event = hookRecord["event"];
183
+ const hookCommand = hookRecord["command"];
184
+ if (typeof event !== "string" || typeof hookCommand !== "string")
185
+ return null;
186
+ if (!MEMORY_HOOK_EVENTS.includes(event))
187
+ return null;
188
+ const timeout = hookRecord["timeout_seconds"];
189
+ hooks.push({
190
+ event: event,
191
+ command: hookCommand,
192
+ timeout_seconds: typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
193
+ ? timeout
194
+ : MEMORY_HOOK_TIMEOUT_SECONDS[event],
195
+ });
196
+ }
197
+ if (hooks.length === 0)
198
+ return null;
199
+ const allow = normalizeStringArray(record["permissions_allow"]);
200
+ const serverId = record["server_id"];
201
+ return {
202
+ server_id: typeof serverId === "string" && serverId.trim()
203
+ ? serverId.trim()
204
+ : MEMORY_MCP_SERVER_ID,
205
+ mcp_server: { command, args, env },
206
+ hooks,
207
+ permissions_allow: allow ?? [...MEMORY_AUTO_APPROVE_TOOLS],
208
+ };
209
+ }
210
+ function normalizeStringArray(value) {
211
+ if (value === undefined)
212
+ return [];
213
+ if (!Array.isArray(value))
214
+ return null;
215
+ if (!value.every((entry) => typeof entry === "string"))
216
+ return null;
217
+ return value;
218
+ }
219
+ function normalizeStringRecord(value) {
220
+ if (value === undefined)
221
+ return {};
222
+ if (!value || typeof value !== "object" || Array.isArray(value))
223
+ return null;
224
+ const out = {};
225
+ for (const [key, entry] of Object.entries(value)) {
226
+ if (typeof entry !== "string")
227
+ return null;
228
+ out[key] = entry;
229
+ }
230
+ return out;
231
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The one filesystem seam `cockpit memory install` writes through.
3
+ *
4
+ * It exists for two reasons. The first is production: every file this installer
5
+ * touches belongs to a host that may be RUNNING while we write — Claude Code
6
+ * rewrites `~/.claude.json` on its own schedule — so the write is staged beside
7
+ * the target and renamed over it, which is the closest thing to atomic either
8
+ * host family offers. A reader never sees a half-written config.
9
+ *
10
+ * The second is proof. The read-back check (BLI-2541) is only worth something if
11
+ * a test can make the stored bytes differ from the written ones; injecting this
12
+ * interface is how `memory-install.test.ts` proves `read_back_mismatch` fires
13
+ * instead of asserting that it would.
14
+ */
15
+ import { chmod, mkdir, readFile, rename, stat, unlink, writeFile, } from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { randomBytes } from "node:crypto";
18
+ import { describeError, isMissingFileFailure } from "../health-detail.js";
19
+ export function defaultMemoryFileIo() {
20
+ return {
21
+ async readText(file) {
22
+ try {
23
+ return await readFile(file, "utf8");
24
+ }
25
+ catch (error) {
26
+ if (isMissingFileFailure(error))
27
+ return null;
28
+ throw error;
29
+ }
30
+ },
31
+ async writeText(file, contents) {
32
+ await mkdir(path.dirname(file), { recursive: true });
33
+ const staged = `${file}.${randomBytes(6).toString("hex")}.tmp`;
34
+ // A staged file is created with the process umask and the rename carries
35
+ // that mode onto the target — so a `~/.claude.json` or `~/.codex/config.toml`
36
+ // the person keeps at 0600 would come out 0644, quietly widened by an
37
+ // installer that was only meant to add one key. Carry the existing mode
38
+ // across; a file that does not exist yet keeps the umask default.
39
+ const existingMode = await stat(file)
40
+ .then((info) => info.mode & 0o777)
41
+ .catch(() => null);
42
+ try {
43
+ await writeFile(staged, contents, "utf8");
44
+ if (existingMode !== null)
45
+ await chmod(staged, existingMode);
46
+ await rename(staged, file);
47
+ }
48
+ catch (error) {
49
+ // A rename that will not land (a locked destination on Windows, a
50
+ // cross-device staging directory) must not lose the write — and it must
51
+ // say which of the two happened, because "install failed" on a config
52
+ // file is exactly the silent failure the fleet contract forbids.
53
+ console.error("[memory-install] staged write could not be renamed; writing in place", JSON.stringify({
54
+ reason: "staged_rename_failed",
55
+ byte_size: contents.length,
56
+ ...describeError(error),
57
+ }));
58
+ await unlink(staged).catch(() => undefined);
59
+ await writeFile(file, contents, "utf8");
60
+ }
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The Codex skill `cockpit memory install` writes to
3
+ * `~/.codex/skills/bli-memory/` (BLI-3580).
4
+ *
5
+ * It replaces the role of the four vendor skills a Codex machine carries today
6
+ * (`supermemory-search`, `-save`, `-forget`, `-login`): one discoverable skill
7
+ * directory with `SKILL.md` as the index and one reference per operation —
8
+ * search, save, update, forget. There is no login reference because there is no
9
+ * fourth credential: the MCP server authenticates with the collector's own
10
+ * device token, the one `cockpit login` already put on the machine.
11
+ *
12
+ * Content is static prose. It is written whole and compared whole, so an
13
+ * install is idempotent to the byte and a drifted copy is replaced rather than
14
+ * merged — the same deal `cockpit agent-rules` offers for its managed block.
15
+ */
16
+ /** Relative path → exact file contents. The map IS the install. */
17
+ export function memoryCodexSkillFiles() {
18
+ return {
19
+ "SKILL.md": SKILL_MD,
20
+ "references/search.md": SEARCH_MD,
21
+ "references/save.md": SAVE_MD,
22
+ "references/update.md": UPDATE_MD,
23
+ "references/forget.md": FORGET_MD,
24
+ };
25
+ }
26
+ const SKILL_MD = `---
27
+ name: bli-memory
28
+ description: BLI Memory — durable memory for this machine. Use when you need to recall what was decided before, or when a session produced a durable decision, preference or correction worth keeping.
29
+ ---
30
+
31
+ # BLI Memory
32
+
33
+ Durable memory lives in BLI's own store, reached through the \`bli-memory\` MCP
34
+ server. Four tools, and nothing else:
35
+
36
+ | Tool | When |
37
+ |---|---|
38
+ | \`search_memory\` | before answering anything that depends on earlier work |
39
+ | \`save_memory\` | a durable decision, preference, correction or constraint appeared |
40
+ | \`update_memory\` | a saved memory is now wrong and has a successor |
41
+ | \`forget_memory\` | a saved memory should never surface again |
42
+
43
+ Rules that do not bend:
44
+
45
+ - **Recall before you assert.** An empty result means the record is silent, not
46
+ that nothing happened. Say which one you are looking at.
47
+ - **Save the decision, not the transcript.** The nightly rollups already
48
+ distil sessions; this store is for the durable line a person would repeat.
49
+ - **Never fuzzy-match when forgetting.** Exact id, or exact text inside one
50
+ container. Deleting the wrong memory is worse than keeping a stale one.
51
+ - **Say what you did.** A save that produced an id says so; a search that
52
+ answered from memory credits it.
53
+
54
+ References: \`references/search.md\`, \`references/save.md\`,
55
+ \`references/update.md\`, \`references/forget.md\`.
56
+
57
+ Installed and kept current by \`cockpit memory install\`. Do not edit by hand —
58
+ the next sync tick replaces it.
59
+ `;
60
+ const SEARCH_MD = `# search_memory
61
+
62
+ \`\`\`
63
+ in : { query: string (1-1000, required),
64
+ containerTag?: string, // omitted -> this machine's default container
65
+ limit?: 1-20 = 5,
66
+ includeProfile?: boolean = true }
67
+ \`\`\`
68
+
69
+ Returns relevance-ranked memories plus, when \`includeProfile\`, the container's
70
+ most recent context. Rows that were forgotten or superseded are unreachable by
71
+ construction and never come back.
72
+
73
+ An empty answer is a finding: report "the record is silent on this", never
74
+ "there is nothing".
75
+ `;
76
+ const SAVE_MD = `# save_memory
77
+
78
+ \`\`\`
79
+ in : { content: string (1-200000, required),
80
+ containerTag?: string,
81
+ customId?: string, // e.g. the session id — the upsert key
82
+ metadata?: object }
83
+ out: { id, containerTag, created|updated, chars }
84
+ \`\`\`
85
+
86
+ Stores what it is given; it does not summarise. Pass \`customId\` when a session
87
+ may save more than once, so the second save updates the first row instead of
88
+ adding a duplicate.
89
+
90
+ Save the durable thing: a decision and its reason, a preference, a correction, a
91
+ constraint discovered the hard way. Do not save the whole transcript.
92
+ `;
93
+ const UPDATE_MD = `# update_memory
94
+
95
+ \`\`\`
96
+ in : { id: uuid (required), content: string (required), metadata?: object }
97
+ out: { newId, supersededId, containerTag, reEmbedded: true }
98
+ \`\`\`
99
+
100
+ An update is an insert plus a link: the new text becomes a new row, the old one
101
+ is marked superseded and stops being reachable from recall while staying
102
+ readable for audit.
103
+
104
+ It refuses — loudly, never silently — when the id is unknown, already forgotten,
105
+ or already superseded. The last case names the successor so you can retry
106
+ against it.
107
+ `;
108
+ const FORGET_MD = `# forget_memory
109
+
110
+ \`\`\`
111
+ in : { id?: uuid, content?: string, containerTag?: string, reason?: string }
112
+ exactly one of id | content; containerTag is REQUIRED with content
113
+ out: { forgotten: [{ id, containerTag, textPreview, created_at }], count }
114
+ \`\`\`
115
+
116
+ Soft delete: the row is marked forgotten and never surfaces through recall
117
+ again. Matching by content is exact after whitespace normalisation — never
118
+ fuzzy, and never widened to every container.
119
+
120
+ It returns what it removed. A bare "done" is not an answer.
121
+ `;
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The smallest TOML surface that can put ONE table into `~/.codex/config.toml`
3
+ * and read it back — and nothing else.
4
+ *
5
+ * This is deliberately not a TOML parser. That file is a person's own Codex
6
+ * configuration: model, approval policy, sandbox, their other MCP servers. The
7
+ * rule is that everything outside `[mcp_servers.bli-memory]` comes out of an
8
+ * install byte-identical, which a parse-and-reserialise round trip cannot
9
+ * promise (it would reorder keys, drop comments and normalise strings). So the
10
+ * writer works on LINE SPANS: find our table's header, find where it ends, swap
11
+ * those lines, leave every other byte alone.
12
+ *
13
+ * The reader is the other half of BLI-2541's rule — verify against what the
14
+ * platform stored, not against what you wrote. It understands exactly the three
15
+ * value forms we emit (a quoted string, a single-line array of strings, a
16
+ * single-line inline table of strings). Anything else in our table reads back as
17
+ * unparsed, which surfaces as `read_back_mismatch` rather than as a shrug.
18
+ */
19
+ /**
20
+ * Finds `[a.b]` (or `["a"."b"]`, or any mix) and the span it owns: everything up
21
+ * to the next table header of any kind, or end of file.
22
+ */
23
+ export function findTomlTableSpan(text, tablePath) {
24
+ const lines = text.split("\n");
25
+ for (let index = 0; index < lines.length; index += 1) {
26
+ const header = parseTableHeader(lines[index] ?? "");
27
+ if (!header || !samePath(header, tablePath))
28
+ continue;
29
+ let end = lines.length;
30
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
31
+ if (parseTableHeader(lines[cursor] ?? "") || isArrayTableHeader(lines[cursor] ?? "")) {
32
+ end = cursor;
33
+ break;
34
+ }
35
+ }
36
+ return { start_line: index, end_line: end };
37
+ }
38
+ return null;
39
+ }
40
+ /** Reads our table's simple key/values back off stored text. Null = no such table. */
41
+ export function readTomlTable(text, tablePath) {
42
+ const span = findTomlTableSpan(text, tablePath);
43
+ if (!span)
44
+ return null;
45
+ const lines = text.split("\n").slice(span.start_line + 1, span.end_line);
46
+ const out = {};
47
+ for (const line of lines) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed || trimmed.startsWith("#"))
50
+ continue;
51
+ const equals = trimmed.indexOf("=");
52
+ if (equals <= 0)
53
+ continue;
54
+ const key = unquoteKey(trimmed.slice(0, equals).trim());
55
+ if (!key)
56
+ continue;
57
+ // `null` means "present but in a form this reader does not model" — the
58
+ // caller reports a mismatch instead of pretending the field matched.
59
+ out[key] = parseTomlValue(trimmed.slice(equals + 1).trim());
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * Replaces our table's lines, or appends the table at the end when it is
65
+ * absent. Everything outside the span is untouched, including trailing
66
+ * whitespace and comments.
67
+ */
68
+ export function upsertTomlTable(text, tablePath, renderedTable) {
69
+ const block = renderedTable.replace(/\n+$/u, "");
70
+ const span = findTomlTableSpan(text, tablePath);
71
+ if (!span) {
72
+ if (!text.trim())
73
+ return `${block}\n`;
74
+ const separator = text.endsWith("\n") ? "\n" : "\n\n";
75
+ return `${text}${separator}${block}\n`;
76
+ }
77
+ const lines = text.split("\n");
78
+ const before = lines.slice(0, span.start_line);
79
+ const after = lines.slice(span.end_line);
80
+ return [...before, ...block.split("\n"), ...after].join("\n");
81
+ }
82
+ /** Removes our table's lines. Used by `--uninstall`-shaped callers and tests. */
83
+ export function removeTomlTable(text, tablePath) {
84
+ const span = findTomlTableSpan(text, tablePath);
85
+ if (!span)
86
+ return text;
87
+ const lines = text.split("\n");
88
+ return [...lines.slice(0, span.start_line), ...lines.slice(span.end_line)].join("\n");
89
+ }
90
+ export function renderTomlTable(tablePath, entries) {
91
+ const header = `[${tablePath.map(renderKey).join(".")}]`;
92
+ const body = entries.map(([key, value]) => `${renderKey(key)} = ${renderValue(value)}`);
93
+ return [header, ...body].join("\n");
94
+ }
95
+ function renderKey(key) {
96
+ return /^[A-Za-z0-9_-]+$/u.test(key) ? key : `"${key.replace(/"/gu, '\\"')}"`;
97
+ }
98
+ function renderValue(value) {
99
+ if (typeof value === "string")
100
+ return renderString(value);
101
+ if (Array.isArray(value))
102
+ return `[${value.map(renderString).join(", ")}]`;
103
+ const pairs = Object.entries(value).map(([key, entry]) => `${renderKey(key)} = ${renderString(entry)}`);
104
+ return pairs.length === 0 ? "{}" : `{ ${pairs.join(", ")} }`;
105
+ }
106
+ function renderString(value) {
107
+ return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"')}"`;
108
+ }
109
+ function parseTableHeader(line) {
110
+ const trimmed = line.trim();
111
+ if (!trimmed.startsWith("[") || trimmed.startsWith("[["))
112
+ return null;
113
+ const close = trimmed.indexOf("]");
114
+ if (close < 0)
115
+ return null;
116
+ // Anything after the closing bracket other than a comment is not a header.
117
+ const rest = trimmed.slice(close + 1).trim();
118
+ if (rest && !rest.startsWith("#"))
119
+ return null;
120
+ return splitKeyPath(trimmed.slice(1, close));
121
+ }
122
+ function isArrayTableHeader(line) {
123
+ return line.trim().startsWith("[[");
124
+ }
125
+ function splitKeyPath(raw) {
126
+ const parts = [];
127
+ let current = "";
128
+ let quote = null;
129
+ for (const character of raw) {
130
+ if (quote) {
131
+ if (character === quote)
132
+ quote = null;
133
+ else
134
+ current += character;
135
+ continue;
136
+ }
137
+ if (character === '"' || character === "'") {
138
+ quote = character;
139
+ continue;
140
+ }
141
+ if (character === ".") {
142
+ parts.push(current.trim());
143
+ current = "";
144
+ continue;
145
+ }
146
+ current += character;
147
+ }
148
+ parts.push(current.trim());
149
+ return parts.filter((part) => part.length > 0);
150
+ }
151
+ function samePath(left, right) {
152
+ return left.length === right.length && left.every((part, index) => part === right[index]);
153
+ }
154
+ function unquoteKey(raw) {
155
+ const trimmed = raw.trim();
156
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) ||
157
+ (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)) {
158
+ return trimmed.slice(1, -1);
159
+ }
160
+ return trimmed;
161
+ }
162
+ function parseTomlValue(raw) {
163
+ const value = stripTrailingComment(raw).trim();
164
+ if (!value)
165
+ return null;
166
+ if (value.startsWith('"') || value.startsWith("'")) {
167
+ return parseTomlString(value);
168
+ }
169
+ if (value.startsWith("[")) {
170
+ if (!value.endsWith("]"))
171
+ return null;
172
+ const inner = value.slice(1, -1).trim();
173
+ if (!inner)
174
+ return [];
175
+ const items = splitTopLevel(inner);
176
+ const parsed = items.map(parseTomlString);
177
+ return parsed.every((item) => item !== null) ? parsed : null;
178
+ }
179
+ if (value.startsWith("{")) {
180
+ if (!value.endsWith("}"))
181
+ return null;
182
+ const inner = value.slice(1, -1).trim();
183
+ const out = {};
184
+ if (!inner)
185
+ return out;
186
+ for (const pair of splitTopLevel(inner)) {
187
+ const equals = pair.indexOf("=");
188
+ if (equals <= 0)
189
+ return null;
190
+ const key = unquoteKey(pair.slice(0, equals).trim());
191
+ const parsed = parseTomlString(pair.slice(equals + 1).trim());
192
+ if (!key || parsed === null)
193
+ return null;
194
+ out[key] = parsed;
195
+ }
196
+ return out;
197
+ }
198
+ return null;
199
+ }
200
+ function parseTomlString(raw) {
201
+ const value = raw.trim();
202
+ if (value.length < 2)
203
+ return null;
204
+ const quote = value[0];
205
+ if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote)
206
+ return null;
207
+ const inner = value.slice(1, -1);
208
+ if (quote === "'")
209
+ return inner;
210
+ return inner.replace(/\\(["\\nrt])/gu, (_match, escaped) => {
211
+ if (escaped === "n")
212
+ return "\n";
213
+ if (escaped === "r")
214
+ return "\r";
215
+ if (escaped === "t")
216
+ return "\t";
217
+ return escaped;
218
+ });
219
+ }
220
+ /** Splits on commas that are not inside a quoted string. */
221
+ function splitTopLevel(raw) {
222
+ const parts = [];
223
+ let current = "";
224
+ let quote = null;
225
+ for (const character of raw) {
226
+ if (quote) {
227
+ current += character;
228
+ if (character === quote)
229
+ quote = null;
230
+ continue;
231
+ }
232
+ if (character === '"' || character === "'") {
233
+ quote = character;
234
+ current += character;
235
+ continue;
236
+ }
237
+ if (character === ",") {
238
+ parts.push(current.trim());
239
+ current = "";
240
+ continue;
241
+ }
242
+ current += character;
243
+ }
244
+ if (current.trim())
245
+ parts.push(current.trim());
246
+ return parts;
247
+ }
248
+ function stripTrailingComment(raw) {
249
+ let quote = null;
250
+ for (let index = 0; index < raw.length; index += 1) {
251
+ const character = raw[index];
252
+ if (quote) {
253
+ if (character === quote)
254
+ quote = null;
255
+ continue;
256
+ }
257
+ if (character === '"' || character === "'") {
258
+ quote = character;
259
+ continue;
260
+ }
261
+ if (character === "#")
262
+ return raw.slice(0, index);
263
+ }
264
+ return raw;
265
+ }