clearotron 0.3.2-beta.13 → 0.3.2-beta.14

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.
@@ -25,6 +25,7 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
25
25
  import { nodeFloorVerdict, nodeFloorRefusal } from "../shared/node-floor.mjs"; // — one floor, read from package.json
26
26
  import { invocationPrefix } from "../shared/invocation.mjs"; // — print a command the reader can type
27
27
  import { watchParent } from "../shared/parent-watch.mjs";
28
+ import { EXPERIMENTAL_WARNING_OFF } from "../shared/demo-start-args.mjs";
28
29
 
29
30
  export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
30
31
 
@@ -193,7 +194,12 @@ const [verb, ...rest] = process.argv.slice(2);
193
194
  // — tell the child how the READER reached us, so its own advice names a command they can type.
194
195
  // Without this every spawned verb sees argv[1] = its own implementation file and would print `npx`
195
196
  // even for somebody who typed a bare `clearotron`.
196
- const child = spawn(process.execPath, [target, ...builtin, ...rest], {
197
+ // THE DEMO RUNS WITHOUT NODE'S EXPERIMENTAL-FEATURE WARNING. Node prints one the first time anything loads
198
+ // its built-in SQLite, and on the demo it landed on the first screen, above the sentence saying what the
199
+ // demo is. A flag on the demo's own process; `bin/example.mjs` hands it on to the services it starts
200
+ // (EXPERIMENTAL_WARNING_OFF). Every other warning still prints.
201
+ const quiet = verb === "demo" ? [EXPERIMENTAL_WARNING_OFF] : [];
202
+ const child = spawn(process.execPath, [...quiet, target, ...builtin, ...rest], {
197
203
  stdio: "inherit",
198
204
  env: { ...process.env, CLEAROTRON_INVOKED_AS: process.argv[1] ?? "" },
199
205
  });
package/bin/example.mjs CHANGED
@@ -38,7 +38,7 @@ import "../shared/env-local.mjs"; // step 4 / — FIRST: this program read a
38
38
  // handed it nothing and the read fell through to a default. Proven both ways from one
39
39
  // environment: without this import the value is invisible, with it the retired spelling is
40
40
  // back-filled. Placed above every other import because a side-effecting import runs in order.
41
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync } from "node:fs";
41
+ import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync } from "node:fs";
42
42
  import { homedir, tmpdir } from "node:os";
43
43
  import { removeDirectory } from "../shared/os-advice.mjs";
44
44
  import { invoke } from "../shared/invocation.mjs"; // — the printed command is resolved once, for the reader who is actually standing there
@@ -53,7 +53,7 @@ import { ensureDemoProgram, demoProgramEnv } from "../shared/permanent-install.m
53
53
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
54
54
 
55
55
  import { usageBlock } from "../shared/usage-block.mjs";
56
- import { demoStartArgs } from "../shared/demo-start-args.mjs";
56
+ import { demoStartArgs, withWarningOff } from "../shared/demo-start-args.mjs";
57
57
  import { bundleVerdict } from "../shared/bundle-freshness.mjs";
58
58
  const argv = process.argv.slice(2);
59
59
  const flag = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
@@ -258,6 +258,14 @@ if (existsSync(poolRoot) && !statSync(poolRoot).isDirectory()) die(`demo: ${pool
258
258
 
259
259
  // ── 3. replay ────────────────────────────────────────────────────────────────────────────────────────
260
260
  console.log(`\n ${BRAND.name} ${BRAND.product.toLowerCase()} — demo\n`);
261
+ // THE LABEL, FIRST. The reader is about to look at a document that reads like advice about a real mark. It
262
+ // is not, and the demo says so before anything else rather than in a footnote nobody reaches. It printed
263
+ // after the replay, below the engine's own diagnostics and a Node warning, so the first thing a newcomer
264
+ // read was internal tallies (measured on the published beta, 2026-09-19).
265
+ console.log(" Real engine output for the fictional mark VENQORI, captured against Clarivate Compumark.");
266
+ console.log(" Replaying it needs no account, no key and no network.");
267
+ console.log(" Every number, band and citation below was produced by that real run and is being");
268
+ console.log(" re-rendered from its artifacts. It is an example, not advice.\n");
261
269
  console.log(samples.length === 1
262
270
  ? ` sample: ${samples[0].dir}`
263
271
  : ` samples: ${samples.length}${failures.length ? ` of ${shipped}` : ""} — ${samples.map((x) => x.name).join(", ")}`);
@@ -276,14 +284,33 @@ const { republishRun } = await import(pathToFileURL(join(REPO, "driver", "publis
276
284
  // The failures are collected and reported together at the end, and the process exits non-zero, because a
277
285
  // demo that came up missing a quarter of itself is not a success however good the three look.
278
286
  const results = [];
279
- for (const s0 of samples) {
280
- try {
281
- // poolUrl "" on purpose: the report's own link block is for a deployment that serves the pool at a
282
- // public URL. This one is served from this process, at a port picked below.
283
- results.push({ ...s0, published: await republishRun({ runId: s0.meta.runId, meta: s0.meta, pool: poolRoot, poolUrl: "", runDir: join(s0.publishFrom, "run") }) });
284
- } catch (e) {
285
- failures.push({ name: s0.name, why: String(e?.message ?? e) });
287
+ // THE PUBLISHER'S OWN DIAGNOSTICS GO TO A FILE. It prints its tallies as it works (`[record-links] …`,
288
+ // `knockout receipts: …`), which is right for an operator watching a delivery and wrong as the first
289
+ // screen a newcomer reads. The replay's output is written to `replay.log` in the folder this demo made,
290
+ // and restored when the replay ends, so everything below prints as before. A failure is not lost: it is
291
+ // collected here and reported after the list.
292
+ const replayLog = join(flag("--pool") ? poolRoot : demoBase, "replay.log");
293
+ const toLog = (chunk, encoding, cb) => {
294
+ try { appendFileSync(replayLog, chunk, typeof encoding === "string" ? encoding : undefined); } catch { /* a log that cannot be written must not stop the replay */ }
295
+ if (typeof encoding === "function") encoding(); else if (typeof cb === "function") cb();
296
+ return true;
297
+ };
298
+ const screen = { out: process.stdout.write, err: process.stderr.write };
299
+ process.stdout.write = toLog;
300
+ process.stderr.write = toLog;
301
+ try {
302
+ for (const s0 of samples) {
303
+ try {
304
+ // poolUrl "" on purpose: the report's own link block is for a deployment that serves the pool at a
305
+ // public URL. This one is served from this process, at a port picked below.
306
+ results.push({ ...s0, published: await republishRun({ runId: s0.meta.runId, meta: s0.meta, pool: poolRoot, poolUrl: "", runDir: join(s0.publishFrom, "run") }) });
307
+ } catch (e) {
308
+ failures.push({ name: s0.name, why: String(e?.message ?? e) });
309
+ }
286
310
  }
311
+ } finally {
312
+ process.stdout.write = screen.out;
313
+ process.stderr.write = screen.err;
287
314
  }
288
315
  // PUBLISHED, SO THE COPIES THEY WERE PUBLISHED FROM GO NOW, whatever comes next.
289
316
  releaseDemoCopies();
@@ -293,12 +320,6 @@ if (!results.length) {
293
320
  }
294
321
  const published = results[0].published;
295
322
 
296
- // THE LABEL. The reader is about to look at a document that reads like advice about a real mark. It is
297
- // not, and the demo says so before the browser opens rather than in a footnote nobody reaches.
298
- console.log(" Real engine output for the fictional mark VENQORI, captured against Clarivate Compumark.");
299
- console.log(" Replaying it needs no account, no key and no network.");
300
- console.log(" Every number, band and citation below was produced by that real run and is being");
301
- console.log(" re-rendered from its artifacts. It is an example, not advice.\n");
302
323
  // NAMES THE POPULATION. This printed "13 finding(s)" beside a report showing
303
324
  // twelve, in the first sentence a reader meets. The number is not wrong — it comes from the audit
304
325
  // spine (`driver/publish/audit-from-spine.mjs`, `counts.findings`), which counts every finding the run
@@ -307,10 +328,8 @@ console.log(" re-rendered from its artifacts. It is an example, not advice.\n")
307
328
  // EACH LANE'S PUBLISHER REPORTS ITS OWN NUMBER, AND THEY ARE NOT THE SAME NUMBER.
308
329
  //
309
330
  // The clearance branch returns `counts.findings` — every finding in the run's audit spine. The knockout
310
- // branch returns no `counts` at all; it returns `receipts`, whose `findings` counts the findings whose
311
- // citations the publisher traced to the run's own held evidence. That is a subset by definition, so it
312
- // is printed in its own words rather than mapped onto the clearance sentence. They happen to agree on
313
- // the demo in this tree, which is one member of a class and proves nothing about the metric.
331
+ // branch returns no `counts` at all; it returns the names it screened and the rating each was given, so
332
+ // its line states those rather than a count mapped onto the clearance sentence.
314
333
  //
315
334
  // The third branch is the point: a lane whose publisher reports no count says so. This line printed a
316
335
  // bare "?" to the knockout — a could-not-look wearing the costume of a number.
@@ -322,9 +341,13 @@ const spineOf = (pub) =>
322
341
  Number.isFinite(pub.counts?.findings)
323
342
  ? `${pub.counts.findings} finding(s) recorded in the run's audit trail; the report shows
324
343
  the ones it retains`
325
- : Number.isFinite(pub.receipts?.findings)
326
- ? `${pub.receipts.findings} finding(s) with citations traced to this run's own held
327
- evidence, on ${pub.receipts.citing}/${pub.receipts.marks} mark(s)`
344
+ // A KNOCKOUT STATES WHAT IT SCREENED AND HOW EACH NAME WAS RATED, in the words its own report opens
345
+ // with ("One name screened: VENQORI, rated Low."). It used to count citations traced to held evidence,
346
+ // which a knockout's register-only findings never carry, so the line read "0 finding(s)" and a stranger
347
+ // took it as "the demo found nothing". Chosen 2026-09-19.
348
+ : Array.isArray(pub.reports) && pub.reports.length
349
+ ? `${pub.reports.length} name${pub.reports.length === 1 ? "" : "s"} screened: ${pub.reports
350
+ .map((r) => (r.band ? `${r.mark}, rated ${r.band}` : r.mark)).join("; ")}; the report shows why`
328
351
  : `this lane's publisher reported no finding count — the report itself is the record`;
329
352
  for (const r of results) console.log(` published: ${r.published.runId}\n ${r.name} — ${spineOf(r.published)}`);
330
353
  // "ONE PER PRODUCT" ONLY WHEN IT IS TRUE: with a sample missing, the count below says how many of how many.
@@ -418,6 +441,10 @@ console.log("");
418
441
  // made, it starts from here as before.
419
442
  const programRoot = ensureDemoProgram({ base: demoBase, say: (line) => console.log(line) });
420
443
  const startFrom = programRoot ?? REPO;
444
+ // The services inherit the demo's Node flag through NODE_OPTIONS, so none of them prints the SQLite warning
445
+ // either. Set on this process's own environment, which both branches below hand on; the reader's own
446
+ // NODE_OPTIONS is kept.
447
+ process.env.NODE_OPTIONS = withWarningOff(process.env.NODE_OPTIONS);
421
448
  const child = spawn(process.execPath, [join(startFrom, "bin", "start.mjs"), ...startArgs], {
422
449
  cwd: startFrom, stdio: ["ignore", "inherit", "inherit"],
423
450
  env: programRoot ? demoProgramEnv(process.env) : process.env,
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "f107c38f60f6cd5536cd30edb3d2b3ad8a1d1d97",
3
- "version": "0.3.2-beta.13"
2
+ "commit": "94613f865f0ca4af3a17122f5d51cbfb7615f738",
3
+ "version": "0.3.2-beta.14"
4
4
  }
@@ -1,5 +1,13 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.2-beta.14
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: The lines for connecting an AI assistant now work when a folder name contains spaces, and the Codex settings block now works on Windows.
8
+ - Fixed: The demo now opens by saying what it shows, without internal diagnostics or a Node warning, and its knockout sample states the rating it reached.
9
+ - Fixed: A knockout report ordered for named countries now lists those countries under Registers counted, instead of a count of the provider's registers.
10
+
3
11
  ## 0.3.2-beta.13
4
12
 
5
13
  ### Patch Changes
@@ -5,6 +5,7 @@
5
5
  // exact server/tool wiring. Two inputs, both produced today by the gateway gather block:
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { dirname, join } from "node:path";
8
+ import { tomlString } from "../../../shared/toml-string.mjs";
8
9
 
9
10
  // • mcpConfig — the claude-shaped JSON string `{mcpServers:{name:{command,args,env,connectionTimeoutMs?}}}`
10
11
  // (buildGatherMcpConfig → JSON.stringify)
@@ -36,17 +37,8 @@ export const CRED_ENV_FORWARD = [
36
37
  ];
37
38
 
38
39
  // ── TOML value escaping (basic strings) ──────────────────────────────────────────────────────────────
39
- export function tomlString(s) {
40
- const str = String(s ?? "");
41
- // TOML basic-string escapes: backslash, double-quote, and the C0 controls TOML names (\b \t \n \f \r).
42
- const esc = str
43
- .replace(/\\/g, "\\\\").replace(/"/g, '\\"')
44
- .replace(/\x08/g, "\\b").replace(/\t/g, "\\t").replace(/\n/g, "\\n")
45
- .replace(/\f/g, "\\f").replace(/\r/g, "\\r")
46
- // any remaining control char → \uXXXX (TOML-legal)
47
- .replace(/[\x00-\x1f]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
48
- return `"${esc}"`;
49
- }
40
+ // One encoder for every TOML block the product writes; re-exported so this module's callers keep their import.
41
+ export { tomlString };
50
42
  function tomlStringArray(arr) {
51
43
  return "[" + (arr || []).map(tomlString).join(", ") + "]";
52
44
  }
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.13",
5
+ "version": "0.3.2-beta.14",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -63,6 +63,7 @@ import { EXPORT_TOGGLE, exportPopover, EXPORT_MENU_JS } from './report-topbar.mj
63
63
  import { makeClassifyStatus, isAllClass } from '../../providers/_shared/screen.mjs';
64
64
  import { saysSomethingNew } from '../../shared/says-something-new.mjs';
65
65
  import { isNextStepHeading } from '../knockout-next-step.mjs';
66
+ import OFFERED from '../../shared/offered-territories.json' with { type: 'json' }; // the order form's own territory names
66
67
 
67
68
  const HERE = dirname(fileURLToPath(import.meta.url));
68
69
 
@@ -481,6 +482,14 @@ function territoryName(code, { bare = false } = {}) {
481
482
  } catch { return ''; }
482
483
  }
483
484
 
485
+ /** A territory the order names, in the order form's own words, or '' when that list has no such code. */
486
+ const ORDER_FORM_NAMES = new Map([...OFFERED.regions, ...OFFERED.countries].map((t) => [t.code, t.name]));
487
+ const orderedTerritoryName = (code) => {
488
+ const c = String(code ?? '').trim().toUpperCase();
489
+ // EM is the European Union register's own code, and some records carry it for an EU order.
490
+ return ORDER_FORM_NAMES.get(c === 'EM' ? 'EU' : c) ?? '';
491
+ };
492
+
484
493
  /** "a, b and c" — the reader's list, not a join on commas. */
485
494
  function listWords(items) {
486
495
  const xs = (items ?? []).filter(Boolean);
@@ -1244,12 +1253,28 @@ function aboutRequestBlock(scope, requestNotes, depthNote = '', productContext =
1244
1253
  // the middle of the panel is the thing a reader skips; the count and its source are what they read.
1245
1254
  const regions = (registerCounts?.scope?.regions ?? []).filter(Boolean);
1246
1255
  const provider = registerCounts?.providerLabel ?? registerCounts?.provider ?? '';
1247
- // THE BOARD'S OWN ROW: "186 registers, on Clarivate Compumark." — registers, because that is what was
1248
- // counted, and no fold: the board lists no territories here, and the workbook's counts sheet carries
1249
- // every register a figure was taken over.
1250
- const counted = regions.length
1251
- ? `${regions.length} ${regions.length === 1 ? 'register' : 'registers'}${provider ? `, on ${provider}` : ''}.`
1252
- : (registerCounts ? `Counted worldwide${provider ? `, on ${provider}` : ''}` : '');
1256
+ // THE ORDER'S TERRITORIES, NOT THE PROVIDER'S REGISTERS (owner, 2026-09-19). The row counted the
1257
+ // registers the provider searched, and for a one-country order that is not one: a United States order is
1258
+ // counted over the United States and the international register, because an international filing can
1259
+ // designate the United States. A client who ordered one country read "2 registers" as a mistake. So an
1260
+ // order that names its territories is stated as it named them, in the order form's own names; the
1261
+ // registers actually counted stay in the line under the counts table. A worldwide order keeps the board's
1262
+ // row, "186 registers, on Clarivate Compumark.", because there the count is the scope (the record says
1263
+ // worldwide by carrying no jurisdictions). A territory the provider does not cover was not counted, so it
1264
+ // is not listed here; it is disclosed where deferrals are. A territory the form's list cannot name, or an
1265
+ // office this install could not reach, falls back to the count rather than printing a code or claiming
1266
+ // a territory nothing was counted in.
1267
+ const on = provider ? `, on ${provider}` : '';
1268
+ const sc = registerCounts?.scope ?? {};
1269
+ const deferred = new Set((sc.deferredJurisdictions ?? []).map((c) => String(c ?? '').trim().toUpperCase()));
1270
+ const ordered = [...new Set((sc.jurisdictions ?? [])
1271
+ .filter((c) => !deferred.has(String(c ?? '').trim().toUpperCase())).map(orderedTerritoryName))];
1272
+ const namesTheOrder = sc.worldwide !== true && !(sc.unreachableOffices ?? []).length
1273
+ && ordered.length > 0 && ordered.every(Boolean);
1274
+ const counted = namesTheOrder ? `${listWords(ordered)}${on}.`
1275
+ : regions.length
1276
+ ? `${regions.length} ${regions.length === 1 ? 'register' : 'registers'}${on}.`
1277
+ : (registerCounts ? `Counted worldwide${on}` : '');
1253
1278
  if (!asked && !where && !classLine && !stage && !context && !counted) return '';
1254
1279
  // ONE PANEL, THE SAME ON BOTH REPORTS (the 2026-09-16 report redesign). The rows carry what was asked for; the
1255
1280
  // counts row says what was counted and hides the territory list behind a fold rather than running a
@@ -201,6 +201,12 @@
201
201
  "skips": 0,
202
202
  "todos": 0
203
203
  },
204
+ "a-connect-line-keeps-every-path-whole.test.mjs": {
205
+ "tests": 8,
206
+ "asserts": 20,
207
+ "skips": 0,
208
+ "todos": 0
209
+ },
204
210
  "a-connect-line-says-where-it-runs.test.mjs": {
205
211
  "tests": 11,
206
212
  "asserts": 56,
@@ -4869,6 +4875,12 @@
4869
4875
  "skips": 0,
4870
4876
  "todos": 0
4871
4877
  },
4878
+ "the-demo-says-what-it-is-first.test.mjs": {
4879
+ "tests": 3,
4880
+ "asserts": 16,
4881
+ "skips": 0,
4882
+ "todos": 0
4883
+ },
4872
4884
  "the-demo-takes-its-folder-with-it.test.mjs": {
4873
4885
  "tests": 6,
4874
4886
  "asserts": 12,
@@ -5051,7 +5063,7 @@
5051
5063
  },
5052
5064
  "the-knockout-page-leads-with-the-read.test.mjs": {
5053
5065
  "tests": 39,
5054
- "asserts": 120,
5066
+ "asserts": 125,
5055
5067
  "skips": 0,
5056
5068
  "todos": 0
5057
5069
  },
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.14
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.2-beta.13
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.2-beta.13",
3
+ "version": "0.3.2-beta.14",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.2-beta.13",
4
+ "version": "0.3.2-beta.14",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.13",
5
+ "version": "0.3.2-beta.14",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.2-beta.14
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.2-beta.13
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.2-beta.13",
3
+ "version": "0.3.2-beta.14",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -12,6 +12,16 @@
12
12
  * `--demo-own-base` says it is the demo's default, which is what lets the supervisor remove the folder
13
13
  * when the window closes. `--keep` is the reader's, and it travels as given.
14
14
  */
15
+ /**
16
+ * The Node flag the demo runs under, so Node's one-time warning that its built-in SQLite is experimental
17
+ * does not land on the demo's first screen. Named once: the launcher puts it on the demo's own process,
18
+ * and `bin/example.mjs` hands it to the services it starts through NODE_OPTIONS.
19
+ */
20
+ export const EXPERIMENTAL_WARNING_OFF = "--disable-warning=ExperimentalWarning";
21
+
22
+ /** The same flag added to whatever NODE_OPTIONS the reader already has, which is kept. PURE. */
23
+ export const withWarningOff = (nodeOptions) => [nodeOptions, EXPERIMENTAL_WARNING_OFF].filter(Boolean).join(" ");
24
+
15
25
  export function demoStartArgs({ demoBase, readerBase = false, keep = false, port = null, noOpen = false } = {}) {
16
26
  const args = ["--demo", "--base", demoBase];
17
27
  if (!readerBase) args.push("--demo-own-base");
@@ -32,6 +32,7 @@
32
32
  import { join } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { stableInstallRoot } from "./permanent-install.mjs";
35
+ import { tomlString } from "./toml-string.mjs";
35
36
 
36
37
  /** The install root — the directory holding `mcp-server/`, resolved from this module rather than cwd. */
37
38
  export const INSTALL_ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..");
@@ -103,15 +104,38 @@ export function stdioConnectOffer(opts = {}) {
103
104
  function envOf({ workDir = null, reportsDir = null } = {}) {
104
105
  return { ...(workDir ? { CLEAROTRON_WORK_DIR: workDir } : {}), ...(reportsDir ? { CLEAROTRON_REPORTS_DIR: reportsDir } : {}) };
105
106
  }
106
- const envFlags = (o) => Object.entries(envOf(o)).map(([k, v]) => ` -e ${k}=${v}`).join("");
107
107
  const envBlock = (o) => (Object.keys(envOf(o)).length ? { env: envOf(o) } : {});
108
108
 
109
+ // ── ONE WORD PER ARGUMENT, IN THE SHELL THAT READS THE LINE ───────────────────────────────────────────
110
+ //
111
+ // The command was the arguments joined with spaces, so a path with a space in it became two arguments:
112
+ // `/tmp/Example User/node/bin/node` reached the assistant as `/tmp/Example` and `User/node/bin/node`
113
+ // (measured on the published beta, 2026-09-19). Each argument is now written as one word for the shell it
114
+ // is pasted into. A word made only of characters no shell treats specially is left bare, so the line for
115
+ // an ordinary install reads exactly as it did.
116
+ //
117
+ // POSIX shells: single quotes, the one quoting in which no character is special. A single quote inside is
118
+ // closed, escaped and reopened.
119
+ const POSIX_BARE = /^[A-Za-z0-9_@%+=:,./-]+$/;
120
+ export const posixWord = (s) => (POSIX_BARE.test(String(s)) ? String(s) : `'${String(s).replace(/'/g, "'\\''")}'`);
121
+ // Windows: cmd and PowerShell both honour double quotes, and the program then splits its command line by
122
+ // the C runtime's rules, under which backslashes are literal except where they run up to a quote; there
123
+ // they are doubled, and a quote inside the word is escaped. A comma or a parenthesis is quoted too, because
124
+ // PowerShell reads a bare one as an array or an expression. `$`, a backtick and `%` cannot be quoted the
125
+ // same way for both shells, and a path holding one is left to the reader.
126
+ const WINDOWS_BARE = /^[A-Za-z0-9_+=:./\\-]+$/;
127
+ export const windowsWord = (s) => {
128
+ const w = String(s);
129
+ if (WINDOWS_BARE.test(w)) return w;
130
+ return `"${w.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
131
+ };
132
+
109
133
  /**
110
134
  * The argument that ends Claude Code's own options. Windows PowerShell 5.1 drops a bare `--` before it
111
135
  * reaches a native program, so the line failed with "missing required argument"; quoted, every shell passes
112
136
  * it through (bash, zsh, PowerShell and cmd alike), so a Windows install is handed the quoted form.
113
137
  */
114
- const separator = (platform = process.platform) => (platform === "win32" ? '"--"' : "--");
138
+ const separator = (windowsShell) => (windowsShell ? '"--"' : "--");
115
139
 
116
140
  /**
117
141
  * WHAT THE HOST ACTUALLY RUNS, and on WSL that is not `node`.
@@ -180,11 +204,17 @@ export const STDIO_SHAPES = Object.freeze({
180
204
  where: null,
181
205
  render: ({ server, workDir, reportsDir, platform, wsl, node }) => {
182
206
  const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
207
+ // WHICH SHELL READS IT. A native Windows install's line is pasted on Windows, and so is the row
208
+ // that crosses into WSL: it exists for an assistant on the Windows side. Everything else is pasted
209
+ // into a POSIX shell.
210
+ const windowsShell = platform === "win32" || l.crossesIntoWsl;
211
+ const word = windowsShell ? windowsWord : posixWord;
183
212
  // The host's own `-e` flags set variables for the process IT starts. Off WSL that is the server;
184
213
  // through the wrapper it is `wsl.exe`, and they stop at the boundary — so on WSL they ride inside
185
214
  // the command instead and this line carries none.
186
- const flags = l.crossesIntoWsl ? "" : envFlags({ workDir, reportsDir });
187
- return `claude mcp add ${STDIO_SERVER_NAME} --scope user${flags} ${separator(platform)} ${l.command} ${l.args.join(" ")}`;
215
+ const flags = l.crossesIntoWsl ? ""
216
+ : Object.entries(envOf({ workDir, reportsDir })).map(([k, v]) => ` -e ${windowsShell ? word(`${k}=${v}`) : `${k}=${word(v)}`}`).join("");
217
+ return `claude mcp add ${STDIO_SERVER_NAME} --scope user${flags} ${separator(windowsShell)} ${[l.command, ...l.args].map(word).join(" ")}`;
188
218
  },
189
219
  after: null,
190
220
  },
@@ -224,7 +254,8 @@ export const STDIO_SHAPES = Object.freeze({
224
254
  kind: "config",
225
255
  where: "~/.codex/config.toml",
226
256
  // Written out rather than produced by a TOML library: this is four lines with no user-supplied
227
- // strings in key positions, and adding a dependency to emit them would be the larger risk.
257
+ // strings in key positions, and adding a dependency to emit them would be the larger risk. Every
258
+ // value goes through `tomlString`, so a Windows path's backslashes are escaped as TOML requires.
228
259
  //
229
260
  // `env` and not `env_vars`, deliberately, and CONNECT.md explains why the distinction matters:
230
261
  // Codex does not forward the shell environment, and a credential would have to be forwarded BY NAME
@@ -234,10 +265,10 @@ export const STDIO_SHAPES = Object.freeze({
234
265
  const l = stdioLauncher({ server, workDir, reportsDir, wsl, node });
235
266
  return [
236
267
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
237
- `command = "${l.command}"`,
238
- `args = [${l.args.map((a) => `"${a}"`).join(", ")}]`,
268
+ `command = ${tomlString(l.command)}`,
269
+ `args = [${l.args.map(tomlString).join(", ")}]`,
239
270
  ...(Object.keys(l.env).length
240
- ? [`env = { ${Object.entries(l.env).map(([k, v]) => `${k} = "${v}"`).join(", ")} }`] : []),
271
+ ? [`env = { ${Object.entries(l.env).map(([k, v]) => `${k} = ${tomlString(v)}`).join(", ")} }`] : []),
241
272
  ].join("\n");
242
273
  },
243
274
  after: null,
@@ -297,7 +328,7 @@ export const REMOTE_SHAPES = Object.freeze({
297
328
  label: "Copy block",
298
329
  render: ({ address }) => [
299
330
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
300
- `url = "${address}"`,
331
+ `url = ${tomlString(address)}`,
301
332
  ].join("\n"),
302
333
  },
303
334
  "claude-cli-http": {
@@ -309,7 +340,7 @@ export const REMOTE_SHAPES = Object.freeze({
309
340
  label: null,
310
341
  render: ({ address }) => [
311
342
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
312
- `url = "${address}"`,
343
+ `url = ${tomlString(address)}`,
313
344
  `bearer_token_env_var = "${KEY_ENV_VAR}"`,
314
345
  ].join("\n"),
315
346
  },
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // ONE TOML STRING ENCODER, for every block this product writes.
5
+ //
6
+ // Two composers write TOML: the engine's per-run Codex configuration and the connection block a reader
7
+ // pastes into `~/.codex/config.toml`. The second wrapped raw values in double quotes, so a Windows path
8
+ // such as `C:\Program Files\nodejs\node.exe` produced a file TOML parsers refuse ("Unescaped '\' in a
9
+ // string"). A value is a TOML string only once it has been through here.
10
+
11
+ /**
12
+ * A TOML basic string holding exactly `s`. PURE.
13
+ *
14
+ * TOML requires the quotation mark, the backslash and every control character other than tab to be
15
+ * escaped (U+0000 to U+0008, U+000A to U+001F, and U+007F). The named escapes are used where TOML has one,
16
+ * and `\uXXXX` for the rest.
17
+ */
18
+ export function tomlString(s) {
19
+ const esc = String(s ?? "")
20
+ .replace(/\\/g, "\\\\").replace(/"/g, '\\"')
21
+ .replace(/\x08/g, "\\b").replace(/\t/g, "\\t").replace(/\n/g, "\\n")
22
+ .replace(/\f/g, "\\f").replace(/\r/g, "\\r")
23
+ .replace(/[\x00-\x1f\x7f]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
24
+ return `"${esc}"`;
25
+ }