@levr-one/cli 0.9.12 → 0.9.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.
@@ -1,23 +1,119 @@
1
1
  import { getApiUrl } from "./env-CHeKHu5S.js";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
- import { delimiter, dirname, join, resolve } from "node:path";
2
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
- import { applyEdits, modify, parse } from "jsonc-parser";
5
+ import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
6
+ import { TomlError, parse as parse$1 } from "smol-toml";
6
7
  import { execFileSync } from "node:child_process";
7
8
 
8
- //#region ../mcp-harnesses/dist/catalog.js
9
+ //#region ../mcp-harnesses/dist/format/toml-render.js
9
10
  /**
10
- * @levr/mcp-harnesses — isomorphic catalog (browser + Node).
11
+ * Value → TOML text, ISOMORPHIC (browser + Node), dependency-free.
12
+ *
13
+ * Two consumers, one implementation (ENG-5264 D5, audit C1):
14
+ * - `buildHarnessConfig` in the isomorphic catalog renders the pasteable
15
+ * snippet a TOML client's card shows — so it must run in the client SPA,
16
+ * which forbids `node:` imports and the `/node/` subpath (catalog.test.ts
17
+ * isomorphic guard).
18
+ * - The node `tomlAdapter` renders the body of the table it writes into the
19
+ * user's config file.
11
20
  *
12
- * Single source of truth for MCP-capable clients ("harnesses"): the catalog,
13
- * the OAuth-client → catalog matcher, and the pure config builders. This module
14
- * MUST stay free of Node built-ins (`node:fs`, `node:os`, `node:path`) so the
15
- * client SPA can import it without pulling `node:fs` into the bundle. Detection
16
- * and config-write live in the `@levr/mcp-harnesses/node` subpath.
21
+ * Written once here so the snippet a user pastes by hand and the bytes the
22
+ * installer writes can never drift apart.
17
23
  *
18
- * Plans: specs/plans/mcp-harness-detect-installer.md (ENG-43, P1) ·
19
- * specs/plans/mcp-install-scopes-ENG-4151.md (ENG-4152, D1 — scope model)
24
+ * Scope is deliberately what `buildServerEntry` produces: string, number,
25
+ * boolean, arrays of those, and nested plain objects. Anything else throws —
26
+ * a TOML file is never the place to discover a serializer guessed.
27
+ */
28
+ /** Keys TOML lets us write without quotes. */
29
+ const BARE_KEY = /^[A-Za-z0-9_-]+$/;
30
+ /** A TOML key, quoted only when it has to be. */
31
+ function renderTomlKey(key) {
32
+ return BARE_KEY.test(key) ? key : renderTomlString(key);
33
+ }
34
+ /**
35
+ * A TOML basic string with every escape TOML requires: backslash, double
36
+ * quote, the C0 controls, and DEL. Everything else (including non-ASCII) is
37
+ * written verbatim, which TOML permits in basic strings.
38
+ */
39
+ function renderTomlString(value) {
40
+ let out = "\"";
41
+ for (const ch of value) {
42
+ const code = ch.codePointAt(0) ?? 0;
43
+ if (ch === "\"") out += "\\\"";
44
+ else if (ch === "\\") out += "\\\\";
45
+ else if (ch === "\n") out += "\\n";
46
+ else if (ch === "\r") out += "\\r";
47
+ else if (ch === " ") out += "\\t";
48
+ else if (ch === "\b") out += "\\b";
49
+ else if (ch === "\f") out += "\\f";
50
+ else if (code < 32 || code === 127) out += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
51
+ else out += ch;
52
+ }
53
+ return out + "\"";
54
+ }
55
+ /** A scalar or array value on the right-hand side of `key = …`. */
56
+ function renderTomlValue(value) {
57
+ if (typeof value === "string") return renderTomlString(value);
58
+ if (typeof value === "boolean") return value ? "true" : "false";
59
+ if (typeof value === "number") {
60
+ if (!Number.isFinite(value)) throw new Error(`cannot render non-finite number ${String(value)} as TOML`);
61
+ return String(value);
62
+ }
63
+ if (Array.isArray(value)) {
64
+ if (value.length === 0) return "[]";
65
+ return `[ ${value.map((v) => renderTomlValue(v)).join(", ")} ]`;
66
+ }
67
+ throw new Error(`cannot render a ${value === null ? "null" : typeof value} as a TOML value`);
68
+ }
69
+ function isPlainObject(value) {
70
+ return value !== null && typeof value === "object" && !Array.isArray(value);
71
+ }
72
+ /**
73
+ * The `key = value` lines for one table body, in the order the object's keys
74
+ * were declared, followed by sub-tables for any nested objects. Nested objects
75
+ * become `[path.key]` sub-tables rather than inline tables so the output stays
76
+ * one key per line — the shape both Codex and Grok Build write themselves.
77
+ *
78
+ * `pathPrefix` is the table this body belongs to, needed to name sub-tables.
79
+ * Returns lines WITHOUT line terminators; the caller picks `\n` or `\r\n`.
80
+ */
81
+ function renderTomlBodyLines(value, pathPrefix) {
82
+ const lines = [];
83
+ const nested = [];
84
+ for (const [key, v] of Object.entries(value)) {
85
+ if (v === void 0) continue;
86
+ if (isPlainObject(v)) {
87
+ nested.push([key, v]);
88
+ continue;
89
+ }
90
+ lines.push(`${renderTomlKey(key)} = ${renderTomlValue(v)}`);
91
+ }
92
+ for (const [key, v] of nested) {
93
+ const path = [...pathPrefix, key];
94
+ lines.push("", `[${path.map(renderTomlKey).join(".")}]`);
95
+ lines.push(...renderTomlBodyLines(v, path));
96
+ }
97
+ return lines;
98
+ }
99
+
100
+ //#endregion
101
+ //#region ../mcp-harnesses/dist/catalog.js
102
+ /** The `{ url, type: "http" }` shape Claude Code and Gemini CLI write. */
103
+ const DEFAULT_NATIVE_HTTP_ENTRY = {
104
+ urlKey: "url",
105
+ constants: { type: "http" }
106
+ };
107
+ /**
108
+ * Provenance sentinel for entries that predate the invariant (ENG-5264 D1,
109
+ * decision ENG-5327). No observation record exists for the harnesses that
110
+ * carry it, and inventing a version or date would be exactly what the
111
+ * invariant forbids — so they say so, greppably: `grep unverified-legacy`
112
+ * names every entry still owing a real observation. An entry is upgraded to a
113
+ * real version + date whenever someone next installs that client, and is never
114
+ * downgraded back. A NEW entry must never be born carrying this value.
20
115
  */
116
+ const UNVERIFIED_LEGACY = "unverified-legacy";
21
117
  /** Stable server key written into every harness config (used by detect/remove).
22
118
  * Renamed from the legacy brand key pre-first-publish (ENG-2515) — this key
23
119
  * is a persisted identity in end-users' client config files, so it must not
@@ -59,11 +155,12 @@ const CLAUDE_CODE_LOCATIONS = [
59
155
  /**
60
156
  * The catalog. Order is presentation order (most common first).
61
157
  *
62
- * `comingSoon` clients (VS Code, Codex) are listed but not installable: their
63
- * config formats differ enough (VS Code's `servers`/native-http schema, Codex's
64
- * TOML) that faithful writes are deferred to a dedicated builder branch. Their
65
- * SCOPES are declared as data regardless, so the support matrix stays complete
66
- * and turning them on later is a builder change, not a catalog change.
158
+ * `comingSoon` clients (VS Code) are listed but not installable: their config
159
+ * format differs enough (VS Code's `servers`/native-http schema) that a
160
+ * faithful write is deferred. Their SCOPES are declared as data regardless, so
161
+ * the support matrix stays complete and turning them on later is a builder
162
+ * change, not a catalog change. Codex left this list in ENG-5264 once the TOML
163
+ * adapter existed.
67
164
  */
68
165
  const HARNESSES = [
69
166
  {
@@ -78,6 +175,8 @@ const HARNESSES = [
78
175
  transport: "mcp-remote",
79
176
  docsUrl: "https://modelcontextprotocol.io/quickstart/user",
80
177
  comingSoon: false,
178
+ verifiedVersion: UNVERIFIED_LEGACY,
179
+ verifiedOn: UNVERIFIED_LEGACY,
81
180
  detectSignals: [
82
181
  {
83
182
  platform: "darwin",
@@ -124,6 +223,8 @@ const HARNESSES = [
124
223
  transport: "native-http",
125
224
  docsUrl: "https://docs.anthropic.com/en/docs/claude-code/mcp",
126
225
  comingSoon: false,
226
+ verifiedVersion: UNVERIFIED_LEGACY,
227
+ verifiedOn: UNVERIFIED_LEGACY,
127
228
  detectSignals: [
128
229
  {
129
230
  platform: "darwin",
@@ -179,6 +280,8 @@ const HARNESSES = [
179
280
  transport: "mcp-remote",
180
281
  docsUrl: "https://cursor.com/docs/mcp",
181
282
  comingSoon: false,
283
+ verifiedVersion: UNVERIFIED_LEGACY,
284
+ verifiedOn: UNVERIFIED_LEGACY,
182
285
  detectSignals: [
183
286
  {
184
287
  platform: "darwin",
@@ -228,6 +331,8 @@ const HARNESSES = [
228
331
  transport: "mcp-remote",
229
332
  docsUrl: "https://docs.windsurf.com/windsurf/mcp",
230
333
  comingSoon: false,
334
+ verifiedVersion: UNVERIFIED_LEGACY,
335
+ verifiedOn: UNVERIFIED_LEGACY,
231
336
  detectSignals: [
232
337
  {
233
338
  platform: "darwin",
@@ -273,6 +378,8 @@ const HARNESSES = [
273
378
  transport: "mcp-remote",
274
379
  docsUrl: "https://zed.dev/docs/ai/mcp",
275
380
  comingSoon: false,
381
+ verifiedVersion: UNVERIFIED_LEGACY,
382
+ verifiedOn: UNVERIFIED_LEGACY,
276
383
  detectSignals: [
277
384
  {
278
385
  platform: "darwin",
@@ -314,6 +421,161 @@ const HARNESSES = [
314
421
  projectPath: ".zed/settings.json"
315
422
  }]
316
423
  },
424
+ {
425
+ id: "gemini",
426
+ label: "Gemini CLI",
427
+ matchers: ["gemini cli", "gemini-cli"],
428
+ serverPropertyName: "mcpServers",
429
+ transport: "native-http",
430
+ docsUrl: "https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md",
431
+ comingSoon: false,
432
+ verifiedVersion: "0.35.1",
433
+ verifiedOn: "2026-09-17",
434
+ detectSignals: [
435
+ {
436
+ platform: "darwin",
437
+ signals: ["which:gemini", "~/.gemini/settings.json"]
438
+ },
439
+ {
440
+ platform: "linux",
441
+ signals: ["which:gemini", "~/.gemini/settings.json"]
442
+ },
443
+ {
444
+ platform: "win32",
445
+ signals: ["which:gemini", "~/.gemini/settings.json"]
446
+ }
447
+ ],
448
+ scopes: [{
449
+ scope: "user",
450
+ installKind: "config-file",
451
+ locations: [
452
+ {
453
+ platform: "darwin",
454
+ configPath: "~/.gemini/settings.json"
455
+ },
456
+ {
457
+ platform: "linux",
458
+ configPath: "~/.gemini/settings.json"
459
+ },
460
+ {
461
+ platform: "win32",
462
+ configPath: "~/.gemini/settings.json"
463
+ }
464
+ ]
465
+ }, {
466
+ scope: "project",
467
+ installKind: "config-file",
468
+ projectPath: ".gemini/settings.json"
469
+ }]
470
+ },
471
+ {
472
+ id: "grok",
473
+ label: "Grok Build",
474
+ matchers: ["grok build", "grok-build"],
475
+ serverPropertyName: "mcp_servers",
476
+ transport: "native-http",
477
+ nativeHttpEntry: {
478
+ urlKey: "url",
479
+ constants: { enabled: true }
480
+ },
481
+ configFormat: "toml",
482
+ docsUrl: "https://docs.x.ai/build/overview",
483
+ comingSoon: false,
484
+ verifiedVersion: "1.0.34",
485
+ verifiedOn: "2026-09-17",
486
+ detectSignals: [
487
+ {
488
+ platform: "darwin",
489
+ signals: ["~/.grok/config.toml", "~/.grok/bin/grok"]
490
+ },
491
+ {
492
+ platform: "linux",
493
+ signals: ["~/.grok/config.toml", "~/.grok/bin/grok"]
494
+ },
495
+ {
496
+ platform: "win32",
497
+ signals: ["~/.grok/config.toml", "~/.grok/bin/grok.exe"]
498
+ }
499
+ ],
500
+ scopes: [{
501
+ scope: "user",
502
+ installKind: "config-file",
503
+ locations: [
504
+ {
505
+ platform: "darwin",
506
+ configPath: "~/.grok/config.toml"
507
+ },
508
+ {
509
+ platform: "linux",
510
+ configPath: "~/.grok/config.toml"
511
+ },
512
+ {
513
+ platform: "win32",
514
+ configPath: "~/.grok/config.toml"
515
+ }
516
+ ]
517
+ }, {
518
+ scope: "project",
519
+ installKind: "config-file",
520
+ projectPath: ".grok/config.toml"
521
+ }]
522
+ },
523
+ {
524
+ id: "antigravity",
525
+ label: "Antigravity",
526
+ matchers: ["antigravity"],
527
+ serverPropertyName: "mcpServers",
528
+ transport: "native-http",
529
+ nativeHttpEntry: {
530
+ urlKey: "serverUrl",
531
+ constants: { disabled: false }
532
+ },
533
+ docsUrl: "https://antigravity.google/docs/mcp",
534
+ comingSoon: false,
535
+ verifiedVersion: "1.2.5",
536
+ verifiedOn: "2026-09-17",
537
+ detectSignals: [
538
+ {
539
+ platform: "darwin",
540
+ signals: [
541
+ "which:agy",
542
+ "~/.local/bin/agy",
543
+ "/Applications/Antigravity IDE.app",
544
+ "~/.gemini/config/mcp_config.json"
545
+ ]
546
+ },
547
+ {
548
+ platform: "linux",
549
+ signals: [
550
+ "which:agy",
551
+ "~/.local/bin/agy",
552
+ "~/.gemini/config/mcp_config.json"
553
+ ]
554
+ },
555
+ {
556
+ platform: "win32",
557
+ signals: ["which:agy", "~/.gemini/config/mcp_config.json"]
558
+ }
559
+ ],
560
+ scopes: [{
561
+ scope: "user",
562
+ installKind: "config-file",
563
+ locations: [
564
+ {
565
+ platform: "darwin",
566
+ configPath: "~/.gemini/config/mcp_config.json"
567
+ },
568
+ {
569
+ platform: "linux",
570
+ configPath: "~/.gemini/config/mcp_config.json"
571
+ },
572
+ {
573
+ platform: "win32",
574
+ configPath: "~/.gemini/config/mcp_config.json"
575
+ }
576
+ ]
577
+ }]
578
+ },
317
579
  {
318
580
  id: "vscode",
319
581
  label: "VS Code",
@@ -367,10 +629,14 @@ const HARNESSES = [
367
629
  id: "codex",
368
630
  label: "Codex CLI",
369
631
  matchers: ["codex"],
370
- serverPropertyName: "mcpServers",
371
- transport: "mcp-remote",
632
+ serverPropertyName: "mcp_servers",
633
+ transport: "native-http",
634
+ nativeHttpEntry: { urlKey: "url" },
635
+ configFormat: "toml",
372
636
  docsUrl: "https://github.com/openai/codex",
373
- comingSoon: true,
637
+ comingSoon: false,
638
+ verifiedVersion: "0.154.0",
639
+ verifiedOn: "2026-09-17",
374
640
  detectSignals: [
375
641
  {
376
642
  platform: "darwin",
@@ -402,10 +668,6 @@ const HARNESSES = [
402
668
  configPath: "~/.codex/config.toml"
403
669
  }
404
670
  ]
405
- }, {
406
- scope: "project",
407
- installKind: "config-file",
408
- projectPath: ".codex/config.toml"
409
671
  }]
410
672
  }
411
673
  ];
@@ -473,7 +735,8 @@ function mcpRemoteInvocation(mcpUrl) {
473
735
  /**
474
736
  * The structured server entry to merge under the scope's server property,
475
737
  * keyed by {@link SERVER_NAME}. Shape is per-transport, then per-property:
476
- * - `native-http` (Claude Code's `.mcp.json`): `{ type: 'http', url }`.
738
+ * - `native-http`: the entry's declared {@link NativeHttpEntryShape} —
739
+ * `{ url, type: 'http' }` by default (Claude Code's `.mcp.json`, Gemini).
477
740
  * - `mcpServers` (Claude Desktop, Cursor, Windsurf): flat `{ command, args }`.
478
741
  * - `context_servers` (Zed): nested `{ source, command: { path, args } }`.
479
742
  *
@@ -481,10 +744,13 @@ function mcpRemoteInvocation(mcpUrl) {
481
744
  */
482
745
  function buildServerEntry(harness, mcpUrl, scope = defaultScope(harness)) {
483
746
  requireScope(harness, scope);
484
- if (harness.transport === "native-http") return { [SERVER_NAME]: {
485
- type: "http",
486
- url: mcpUrl
487
- } };
747
+ if (harness.transport === "native-http") {
748
+ const shape = harness.nativeHttpEntry ?? DEFAULT_NATIVE_HTTP_ENTRY;
749
+ return { [SERVER_NAME]: {
750
+ [shape.urlKey]: mcpUrl,
751
+ ...shape.constants ?? {}
752
+ } };
753
+ }
488
754
  const { command, args } = mcpRemoteInvocation(mcpUrl);
489
755
  if (serverPropertyFor(harness, scope) === "context_servers") return { [SERVER_NAME]: {
490
756
  source: "custom",
@@ -724,6 +990,428 @@ function getAtPath(obj, path) {
724
990
  return cur;
725
991
  }
726
992
 
993
+ //#endregion
994
+ //#region ../mcp-harnesses/dist/node/format/port.js
995
+ /**
996
+ * Outbound port: a config DOCUMENT the installer can read a value out of and
997
+ * surgically edit, without the domain knowing what syntax the file is in.
998
+ *
999
+ * `installHarnessSync` / `removeHarnessSync` (`../install.ts`) and detection
1000
+ * (`../detect.ts`) speak only this interface; each concrete format lives in a
1001
+ * sibling adapter and is selected by the DECLARATIVE `configFormat` field on
1002
+ * the catalog entry (`adapterFor`, `./index.ts`). No harness id, and no format
1003
+ * name, ever reaches the domain — plan ENG-5264 §2.
1004
+ *
1005
+ * ## The failure channel is part of the contract (review F-002)
1006
+ *
1007
+ * A read has THREE outcomes, not two. `absent` is the state the domain answers
1008
+ * by writing a fresh entry into an empty document; `unsupported` is the state
1009
+ * it must REFUSE on — a non-empty file that does not parse, or a document in
1010
+ * which our key sits in a shape the adapter cannot edit in place. Collapsing
1011
+ * the two (returning `undefined` for both) is how a real user config gets
1012
+ * treated as empty and overwritten. That is why `readAt` returns a tagged
1013
+ * union rather than `unknown`, and why the mutators throw a typed error
1014
+ * instead of best-effort restructuring a document they did not understand.
1015
+ */
1016
+ /**
1017
+ * Thrown by {@link ConfigDocumentAdapter.setAt} / `removeAt` when the edit
1018
+ * cannot be made without restructuring content the adapter does not fully
1019
+ * understand. The domain catches it and refuses the install — fail closed.
1020
+ */
1021
+ var UnsupportedConfigShapeError = class extends Error {
1022
+ detail;
1023
+ constructor(detail) {
1024
+ super(detail);
1025
+ this.detail = detail;
1026
+ this.name = "UnsupportedConfigShapeError";
1027
+ }
1028
+ };
1029
+
1030
+ //#endregion
1031
+ //#region ../mcp-harnesses/dist/node/format/jsonc.js
1032
+ const FORMAT = {
1033
+ insertSpaces: true,
1034
+ tabSize: 2,
1035
+ eol: "\n"
1036
+ };
1037
+ /**
1038
+ * Parse strictly enough to be safe: any syntax error is a refusal, never a
1039
+ * partial document. Returns the root, or the reason it cannot be used.
1040
+ */
1041
+ function parseDocument$1(text) {
1042
+ const errors = [];
1043
+ const root = parse(text, errors, {
1044
+ allowTrailingComma: true,
1045
+ disallowComments: false
1046
+ });
1047
+ const first = errors[0];
1048
+ if (first) return {
1049
+ ok: false,
1050
+ detail: `document does not parse (${printParseErrorCode(first.error)} at offset ${first.offset})`
1051
+ };
1052
+ if (root === null || typeof root !== "object" || Array.isArray(root)) return {
1053
+ ok: false,
1054
+ detail: "document root is not an object"
1055
+ };
1056
+ return {
1057
+ ok: true,
1058
+ root
1059
+ };
1060
+ }
1061
+ const jsoncAdapter = {
1062
+ empty: "{}",
1063
+ readAt(text, path) {
1064
+ if (text.trim() === "") return { kind: "absent" };
1065
+ const doc = parseDocument$1(text);
1066
+ if (!doc.ok) return {
1067
+ kind: "unsupported",
1068
+ detail: doc.detail
1069
+ };
1070
+ const value = getAtPath(doc.root, path);
1071
+ return value === void 0 ? { kind: "absent" } : {
1072
+ kind: "value",
1073
+ value
1074
+ };
1075
+ },
1076
+ setAt(text, path, value) {
1077
+ return edit(text, path, value);
1078
+ },
1079
+ removeAt(text, path) {
1080
+ return edit(text, path, void 0);
1081
+ }
1082
+ };
1083
+ /** Shared by set and remove: jsonc-parser removes a key when `value` is `undefined`. */
1084
+ function edit(text, path, value) {
1085
+ const doc = parseDocument$1(text);
1086
+ if (!doc.ok) throw new UnsupportedConfigShapeError(doc.detail);
1087
+ try {
1088
+ return applyEdits(text, modify(text, path, value, { formattingOptions: FORMAT }));
1089
+ } catch (err) {
1090
+ const message = err instanceof Error ? err.message : String(err);
1091
+ throw new UnsupportedConfigShapeError(`cannot edit path ${path.join(".")}: ${message}`);
1092
+ }
1093
+ }
1094
+
1095
+ //#endregion
1096
+ //#region ../mcp-harnesses/dist/node/format/toml.js
1097
+ /**
1098
+ * Suffix of the one-shot sibling backup the domain writes before the FIRST
1099
+ * modifying write to a TOML target (review F-005): `config.toml.levr-bak`.
1100
+ * Declared here, applied format-generically by the installer.
1101
+ */
1102
+ const TOML_BACKUP_SUFFIX = ".levr-bak";
1103
+ const BOM = "";
1104
+ function parseDocument(text) {
1105
+ try {
1106
+ return {
1107
+ ok: true,
1108
+ root: parse$1(stripBom(text))
1109
+ };
1110
+ } catch (err) {
1111
+ if (err instanceof TomlError) return {
1112
+ ok: false,
1113
+ detail: `document does not parse: ${err.message.split("\n")[0] ?? err.message} (line ${err.line}, col ${err.column})`
1114
+ };
1115
+ return {
1116
+ ok: false,
1117
+ detail: `document does not parse: ${err instanceof Error ? err.message : String(err)}`
1118
+ };
1119
+ }
1120
+ }
1121
+ /**
1122
+ * `smol-toml` does not strip a UTF-8 byte-order mark, and Windows editors
1123
+ * write one (code review F-006). The BOM is document decoration: parse and
1124
+ * edit the text behind it, put it back on the way out.
1125
+ */
1126
+ function stripBom(text) {
1127
+ return text.startsWith(BOM) ? text.slice(1) : text;
1128
+ }
1129
+ function splitLines(text) {
1130
+ const lines = [];
1131
+ const eols = [];
1132
+ let crlf = 0;
1133
+ let lf = 0;
1134
+ let from = 0;
1135
+ for (let i = 0; i < text.length; i++) {
1136
+ if (text[i] !== "\n") continue;
1137
+ const cr = i > 0 && text[i - 1] === "\r";
1138
+ lines.push(text.slice(from, cr ? i - 1 : i));
1139
+ eols.push(cr ? "\r\n" : "\n");
1140
+ if (cr) crlf++;
1141
+ else lf++;
1142
+ from = i + 1;
1143
+ }
1144
+ lines.push(text.slice(from));
1145
+ eols.push("");
1146
+ return {
1147
+ lines,
1148
+ eols,
1149
+ eol: crlf > lf ? "\r\n" : "\n"
1150
+ };
1151
+ }
1152
+ function joinLines(lines, eols) {
1153
+ let out = "";
1154
+ for (let i = 0; i < lines.length; i++) out += lines[i] + (eols[i] ?? "");
1155
+ return out;
1156
+ }
1157
+ /** Leading whitespace then `[` — the shape every table header shares. */
1158
+ function isBracketLed(line) {
1159
+ return /^[ \t]*\[/.test(line);
1160
+ }
1161
+ /**
1162
+ * The key path of a `[table.header]` line, or `undefined` when the line is
1163
+ * not a standard-table header this scanner can read. Array-of-tables
1164
+ * (`[[x]]`) and comment lines never match; a trailing `# comment` may
1165
+ * contain anything, brackets included. Segments may be bare, `"basic"` (with
1166
+ * escapes) or `'literal'` keys.
1167
+ *
1168
+ * `undefined` means "not OUR table" and nothing more — see the module
1169
+ * comment for why an unreadable header still ends a body.
1170
+ */
1171
+ function headerPathOf(line) {
1172
+ let i = 0;
1173
+ while (line[i] === " " || line[i] === " ") i++;
1174
+ if (line[i] !== "[" || line[i + 1] === "[") return void 0;
1175
+ i++;
1176
+ const segments = [];
1177
+ let current = "";
1178
+ let sawSegment = false;
1179
+ let closed = false;
1180
+ while (i < line.length) {
1181
+ const ch = line[i];
1182
+ if (ch === " " || ch === " ") {
1183
+ i++;
1184
+ continue;
1185
+ }
1186
+ if (ch === "]") {
1187
+ closed = true;
1188
+ i++;
1189
+ break;
1190
+ }
1191
+ if (ch === "\"") {
1192
+ const end = scanBasicString(line, i);
1193
+ if (end === -1) return void 0;
1194
+ const unescaped = unescapeBasic(line.slice(i + 1, end));
1195
+ if (unescaped === void 0) return void 0;
1196
+ current += unescaped;
1197
+ i = end + 1;
1198
+ sawSegment = true;
1199
+ continue;
1200
+ }
1201
+ if (ch === "'") {
1202
+ const close = line.indexOf("'", i + 1);
1203
+ if (close === -1) return void 0;
1204
+ current += line.slice(i + 1, close);
1205
+ i = close + 1;
1206
+ sawSegment = true;
1207
+ continue;
1208
+ }
1209
+ if (ch === ".") {
1210
+ if (!sawSegment) return void 0;
1211
+ segments.push(current);
1212
+ current = "";
1213
+ sawSegment = false;
1214
+ i++;
1215
+ continue;
1216
+ }
1217
+ if (/[A-Za-z0-9_-]/.test(ch)) {
1218
+ current += ch;
1219
+ sawSegment = true;
1220
+ i++;
1221
+ continue;
1222
+ }
1223
+ return;
1224
+ }
1225
+ if (!closed || !sawSegment) return void 0;
1226
+ const rest = line.slice(i);
1227
+ if (!/^[ \t]*(#.*)?$/.test(rest)) return void 0;
1228
+ segments.push(current);
1229
+ return segments;
1230
+ }
1231
+ /** Index of the closing quote of the basic string opening at `open`, or -1. */
1232
+ function scanBasicString(line, open) {
1233
+ for (let i = open + 1; i < line.length; i++) {
1234
+ if (line[i] === "\\") {
1235
+ i++;
1236
+ continue;
1237
+ }
1238
+ if (line[i] === "\"") return i;
1239
+ }
1240
+ return -1;
1241
+ }
1242
+ /** TOML basic-string escapes are a subset of JSON's, so JSON can decode them. */
1243
+ function unescapeBasic(raw) {
1244
+ try {
1245
+ const value = JSON.parse(`"${raw}"`);
1246
+ return typeof value === "string" ? value : void 0;
1247
+ } catch {
1248
+ return;
1249
+ }
1250
+ }
1251
+ const PROBE_KEY = "__levr_header_probe__";
1252
+ /**
1253
+ * What is the bracket-led line at `idx`? Swap it for a probe header and ask
1254
+ * the parser:
1255
+ *
1256
+ * - `'real'`: the probe table appeared at the root — the line was a genuine
1257
+ * header at document level.
1258
+ * - `'text'`: the document still parses and the probe did NOT appear — the
1259
+ * line is content inside a multi-line string. It changed the string, not
1260
+ * the document's shape.
1261
+ * - `'unknown'`: the swap broke the document. Nothing is proven either way.
1262
+ */
1263
+ function probeHeader(doc, idx) {
1264
+ const probe = [...doc.lines];
1265
+ probe[idx] = `[${PROBE_KEY}]`;
1266
+ const parsed = parseDocument(joinLines(probe, doc.eols));
1267
+ if (!parsed.ok) return "unknown";
1268
+ return Object.prototype.hasOwnProperty.call(parsed.root, PROBE_KEY) ? "real" : "text";
1269
+ }
1270
+ function samePath(a, b) {
1271
+ return a.length === b.length && a.every((s, i) => s === b[i]);
1272
+ }
1273
+ function isSubPath(path, prefix) {
1274
+ return path.length > prefix.length && prefix.every((s, i) => s === path[i]);
1275
+ }
1276
+ /**
1277
+ * Locate our table: the header line index and the index just past its body.
1278
+ * `undefined` when there is no standalone header for `path`.
1279
+ *
1280
+ * The START must be a fully parsed header for exactly `path`, confirmed
1281
+ * real. The END is the first bracket-led line after it that is neither one
1282
+ * of our own sub-tables nor proven to be string content — a header the path
1283
+ * scanner cannot read, or one whose probe breaks the document, still ends
1284
+ * the body. Misjudging a line can only make our region smaller.
1285
+ */
1286
+ function locateTable(doc, path) {
1287
+ const { lines } = doc;
1288
+ let start = -1;
1289
+ for (let i = 0; i < lines.length; i++) {
1290
+ const hp = headerPathOf(lines[i]);
1291
+ if (hp && samePath(hp, path) && probeHeader(doc, i) === "real") {
1292
+ start = i;
1293
+ break;
1294
+ }
1295
+ }
1296
+ if (start === -1) return void 0;
1297
+ const last = lines.length - 1;
1298
+ const limit = lines[last] === "" ? last : lines.length;
1299
+ let end = limit;
1300
+ for (let i = start + 1; i < limit; i++) {
1301
+ if (!isBracketLed(lines[i])) continue;
1302
+ const verdict = probeHeader(doc, i);
1303
+ if (verdict === "text") continue;
1304
+ const hp = headerPathOf(lines[i]);
1305
+ if (hp && isSubPath(hp, path) && verdict === "real") continue;
1306
+ end = i;
1307
+ break;
1308
+ }
1309
+ while (end - 1 > start && /^[ \t]*(#.*)?$/.test(lines[end - 1])) end--;
1310
+ return {
1311
+ start,
1312
+ end
1313
+ };
1314
+ }
1315
+ function requireEditable(text, path) {
1316
+ const parsed = parseDocument(text);
1317
+ if (!parsed.ok) throw new UnsupportedConfigShapeError(parsed.detail);
1318
+ return {
1319
+ doc: splitLines(text),
1320
+ exists: getAtPath(parsed.root, path) !== void 0
1321
+ };
1322
+ }
1323
+ function notStandalone(path) {
1324
+ return new UnsupportedConfigShapeError(`${path.join(".")} exists but not as a standalone [${path.join(".")}] table (inline table or dotted key) — refusing to restructure it`);
1325
+ }
1326
+ const tomlAdapter = {
1327
+ empty: "",
1328
+ backupSuffix: TOML_BACKUP_SUFFIX,
1329
+ readAt(text, path) {
1330
+ if (stripBom(text).trim() === "") return { kind: "absent" };
1331
+ const doc = parseDocument(text);
1332
+ if (!doc.ok) return {
1333
+ kind: "unsupported",
1334
+ detail: doc.detail
1335
+ };
1336
+ const value = getAtPath(doc.root, path);
1337
+ return value === void 0 ? { kind: "absent" } : {
1338
+ kind: "value",
1339
+ value
1340
+ };
1341
+ },
1342
+ setAt(text, path, value) {
1343
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new UnsupportedConfigShapeError(`a TOML table body must be an object, got ${value === null ? "null" : Array.isArray(value) ? "array" : typeof value}`);
1344
+ const body = value;
1345
+ const bom = text.startsWith(BOM) ? BOM : "";
1346
+ const inner = stripBom(text);
1347
+ if (inner.trim() === "") return bom + renderTable(path, body, inner.includes("\r\n") ? "\r\n" : "\n");
1348
+ const { doc, exists } = requireEditable(inner, path);
1349
+ const table = locateTable(doc, path);
1350
+ if (!table) {
1351
+ if (exists) throw notStandalone(path);
1352
+ const { eol } = doc;
1353
+ let base = inner.endsWith("\n") ? inner : inner + eol;
1354
+ if (!/\r?\n\r?\n$/.test(base)) base += eol;
1355
+ return bom + base + renderTable(path, body, eol);
1356
+ }
1357
+ const rendered = renderTomlBodyLines(body, path);
1358
+ const atEof = table.end === doc.lines.length;
1359
+ return bom + joinLines([
1360
+ ...doc.lines.slice(0, table.start + 1),
1361
+ ...rendered,
1362
+ ...doc.lines.slice(table.end)
1363
+ ], [
1364
+ ...doc.eols.slice(0, table.start + 1),
1365
+ ...rendered.map((_, i) => atEof && i === rendered.length - 1 ? "" : doc.eol),
1366
+ ...doc.eols.slice(table.end)
1367
+ ]);
1368
+ },
1369
+ removeAt(text, path) {
1370
+ const bom = text.startsWith(BOM) ? BOM : "";
1371
+ const inner = stripBom(text);
1372
+ if (inner.trim() === "") return text;
1373
+ const { doc, exists } = requireEditable(inner, path);
1374
+ if (!exists) return text;
1375
+ const table = locateTable(doc, path);
1376
+ if (!table) throw notStandalone(path);
1377
+ const lines = [...doc.lines.slice(0, table.start), ...doc.lines.slice(table.end)];
1378
+ const eols = [...doc.eols.slice(0, table.start), ...doc.eols.slice(table.end)];
1379
+ const at = table.start;
1380
+ if (at === 0) {
1381
+ if (lines[0] === "" && lines.length > 1) {
1382
+ lines.splice(0, 1);
1383
+ eols.splice(0, 1);
1384
+ }
1385
+ } else if (lines[at - 1] === "" && (at >= lines.length || lines[at] === "")) {
1386
+ lines.splice(at - 1, 1);
1387
+ eols.splice(at - 1, 1);
1388
+ }
1389
+ return bom + joinLines(lines, eols);
1390
+ }
1391
+ };
1392
+ function renderTable(path, body, eol) {
1393
+ return [`[${path.map(renderTomlKey).join(".")}]`, ...renderTomlBodyLines(body, path)].join(eol) + eol;
1394
+ }
1395
+
1396
+ //#endregion
1397
+ //#region ../mcp-harnesses/dist/node/format/index.js
1398
+ /** Every syntax the installer can write, keyed by the catalog's `configFormat`. */
1399
+ const ADAPTERS = {
1400
+ jsonc: jsoncAdapter,
1401
+ toml: tomlAdapter
1402
+ };
1403
+ /** The format a harness declares, with the catalog's documented default. */
1404
+ function configFormatOf(harness) {
1405
+ return harness.configFormat ?? "jsonc";
1406
+ }
1407
+ /** The adapter for this harness's config syntax. */
1408
+ function adapterFor(harness) {
1409
+ const format = configFormatOf(harness);
1410
+ const adapter = ADAPTERS[format];
1411
+ if (!adapter) throw new Error(`no config adapter registered for configFormat "${format}" (declared by harness "${harness.id}")`);
1412
+ return adapter;
1413
+ }
1414
+
727
1415
  //#endregion
728
1416
  //#region ../mcp-harnesses/dist/node/detect.js
729
1417
  /**
@@ -737,8 +1425,8 @@ function getAtPath(obj, path) {
737
1425
  function isServerConfigured(harness, scope, configPath, cwdKey) {
738
1426
  const text = readTextOrNull(configPath);
739
1427
  if (!text) return false;
740
- const val = getAtPath(parse(text), entryPathFor(harness, scope, cwdKey));
741
- return val !== void 0 && val !== null;
1428
+ const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope, cwdKey));
1429
+ return read.kind === "value" && read.value !== null;
742
1430
  }
743
1431
  function detectScope(harness, env, def) {
744
1432
  const configPath = resolveConfigPath(harness, env, def.scope);
@@ -867,11 +1555,6 @@ function runHarnessCommandSync(argv, opts = {}) {
867
1555
 
868
1556
  //#endregion
869
1557
  //#region ../mcp-harnesses/dist/node/install.js
870
- const FORMAT = {
871
- insertSpaces: true,
872
- tabSize: 2,
873
- eol: "\n"
874
- };
875
1558
  /**
876
1559
  * Resolve the target path for a scope, or the reason it cannot be resolved.
877
1560
  * Shared by install and remove so the two can never disagree about where a
@@ -901,7 +1584,11 @@ function resolveTarget(harness, env, scope) {
901
1584
  function currentEntry(harness, env, scope) {
902
1585
  const configPath = resolveConfigPath(harness, env, scope);
903
1586
  if (!configPath) return { present: false };
904
- const entry = readServerEntry(harness, scope, configPath, env.cwd, (t) => parse(t));
1587
+ const adapter = adapterFor(harness);
1588
+ const entry = readServerEntry(harness, scope, configPath, env.cwd, (t) => {
1589
+ const read = adapter.readAt(t, []);
1590
+ return read.kind === "value" ? read.value : void 0;
1591
+ });
905
1592
  if (entry === void 0 || entry === null) return { present: false };
906
1593
  const e = entry;
907
1594
  if (typeof e.url === "string") return {
@@ -917,9 +1604,19 @@ function currentEntry(harness, env, scope) {
917
1604
  }
918
1605
  return { present: true };
919
1606
  }
920
- /** JSON-structural equality — sufficient for our small config values. */
1607
+ /**
1608
+ * Structural equality, key order ignored. A client that writes its keys in a
1609
+ * different order than we do (Antigravity sorts them; older versions of our
1610
+ * own writer put `type` before `url`) must still read as already configured,
1611
+ * or every install becomes a perpetual rewrite.
1612
+ */
921
1613
  function sameValue(a, b) {
922
- return JSON.stringify(a) === JSON.stringify(b);
1614
+ return canonical(a) === canonical(b);
1615
+ }
1616
+ function canonical(value) {
1617
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
1618
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([x], [y]) => x < y ? -1 : x > y ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
1619
+ return JSON.stringify(value) ?? "undefined";
923
1620
  }
924
1621
  /** Merge (or preview) our MCP into a harness config. */
925
1622
  function installHarnessSync(harness, mcpUrl, opts = {}) {
@@ -993,11 +1690,19 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
993
1690
  const { path } = target;
994
1691
  const entryValue = buildServerEntry(harness, mcpUrl, scope)[SERVER_NAME];
995
1692
  const modPath = entryPathFor(harness, scope, env.cwd);
1693
+ const adapter = adapterFor(harness);
996
1694
  const existing = readTextOrNull(path);
997
- const baseText = existing && existing.trim() ? existing : "{}";
998
- const current = getAtPath(parse(baseText), modPath);
999
- const alreadyConfigured = current !== void 0 && sameValue(current, entryValue);
1000
- const nextText = applyEdits(baseText, modify(baseText, modPath, entryValue, { formattingOptions: FORMAT }));
1695
+ const baseText = existing && existing.trim() ? existing : adapter.empty;
1696
+ const current = adapter.readAt(baseText, modPath);
1697
+ if (current.kind === "unsupported") return unsupportedShape(path, dryRun, scope, current.detail);
1698
+ const alreadyConfigured = current.kind === "value" && sameValue(current.value, entryValue);
1699
+ let nextText;
1700
+ try {
1701
+ nextText = adapter.setAt(baseText, modPath, entryValue);
1702
+ } catch (err) {
1703
+ if (err instanceof UnsupportedConfigShapeError) return unsupportedShape(path, dryRun, scope, err.detail);
1704
+ throw err;
1705
+ }
1001
1706
  if (alreadyConfigured) return {
1002
1707
  ok: true,
1003
1708
  wrote: false,
@@ -1007,6 +1712,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1007
1712
  scope,
1008
1713
  preview: nextText
1009
1714
  };
1715
+ const backupPath = backupPathFor(path, adapter.backupSuffix);
1010
1716
  if (dryRun) return {
1011
1717
  ok: true,
1012
1718
  wrote: false,
@@ -1014,11 +1720,17 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1014
1720
  alreadyConfigured: false,
1015
1721
  dryRun: true,
1016
1722
  scope,
1017
- preview: nextText
1723
+ preview: nextText,
1724
+ ...backupPath ? { backupPath } : {}
1018
1725
  };
1019
- mkdirSync(dirname(path), { recursive: true });
1020
1726
  const finalText = nextText.endsWith("\n") ? nextText : `${nextText}\n`;
1021
- writeFileSync(path, finalText, "utf8");
1727
+ try {
1728
+ mkdirSync(dirname(path), { recursive: true });
1729
+ if (backupPath) backupOnce(path, backupPath);
1730
+ writeConfigAtomic(path, finalText);
1731
+ } catch (err) {
1732
+ return writeFailed(path, scope, err);
1733
+ }
1022
1734
  return {
1023
1735
  ok: true,
1024
1736
  wrote: true,
@@ -1026,9 +1738,82 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1026
1738
  alreadyConfigured: false,
1027
1739
  dryRun: false,
1028
1740
  scope,
1029
- preview: finalText
1741
+ preview: finalText,
1742
+ ...backupPath ? { backupPath } : {}
1743
+ };
1744
+ }
1745
+ /**
1746
+ * Where the one-shot backup of an existing `path` lives, for an adapter that
1747
+ * asks for one — `undefined` when the format does not, or when there is no
1748
+ * original to protect yet.
1749
+ */
1750
+ function backupPathFor(path, suffix) {
1751
+ if (!suffix || !existsSync(path)) return void 0;
1752
+ return `${path}${suffix}`;
1753
+ }
1754
+ /** Copy the original once. An existing backup is never overwritten. */
1755
+ function backupOnce(path, backupPath) {
1756
+ if (existsSync(backupPath)) return;
1757
+ copyFileSync(path, backupPath);
1758
+ }
1759
+ /** The refusal every format shares: the file is there, and we will not touch it. */
1760
+ function unsupportedShape(path, dryRun, scope, detail) {
1761
+ return {
1762
+ ok: false,
1763
+ wrote: false,
1764
+ path,
1765
+ alreadyConfigured: false,
1766
+ dryRun,
1767
+ scope,
1768
+ reason: "unsupported-config-shape",
1769
+ detail
1030
1770
  };
1031
1771
  }
1772
+ /** The filesystem said no: report it per client instead of aborting the run. */
1773
+ function writeFailed(path, scope, err) {
1774
+ return {
1775
+ ok: false,
1776
+ wrote: false,
1777
+ path,
1778
+ alreadyConfigured: false,
1779
+ dryRun: false,
1780
+ scope,
1781
+ reason: "write-failed",
1782
+ detail: err instanceof Error ? err.message : String(err)
1783
+ };
1784
+ }
1785
+ /**
1786
+ * Replace `path`'s contents atomically: the new text goes to a sibling temp
1787
+ * file that is then renamed over the target, so an interrupted write (ENOSPC,
1788
+ * a kill, sleep) leaves the ORIGINAL file intact rather than a truncated one,
1789
+ * and a concurrent reader sees either the old document or the new one. The
1790
+ * original mode is preserved (a `0600` config stays `0600`).
1791
+ *
1792
+ * This matters more than it did: a harness config can be the file that also
1793
+ * holds a user's model providers, profiles and sandbox policy (review F-005).
1794
+ */
1795
+ function writeConfigAtomic(path, text) {
1796
+ let target = path;
1797
+ let mode;
1798
+ try {
1799
+ target = realpathSync(path);
1800
+ mode = statSync(target).mode & 511;
1801
+ } catch {}
1802
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
1803
+ writeFileSync(tmp, text, {
1804
+ encoding: "utf8",
1805
+ ...mode !== void 0 ? { mode } : {}
1806
+ });
1807
+ try {
1808
+ if (mode !== void 0) chmodSync(tmp, mode);
1809
+ renameSync(tmp, target);
1810
+ } catch (err) {
1811
+ try {
1812
+ unlinkSync(tmp);
1813
+ } catch {}
1814
+ throw err;
1815
+ }
1816
+ }
1032
1817
 
1033
1818
  //#endregion
1034
1819
  //#region src/mcp/url.ts
@@ -1258,6 +2043,8 @@ function failureText(o) {
1258
2043
  case "not-a-repo": return "project scope needs a git repository (run from inside one)";
1259
2044
  case "scope-collision": return `${r.scope} scope resolves to the same file as ${r.collidesWith ?? "another"} scope here — refusing rather than overwriting it`;
1260
2045
  case "url-mismatch": return "already configured with a different URL" + (r.currentUrl ? ` (${r.currentUrl})` : "") + "; remove it first, then re-run";
2046
+ case "write-failed": return `its config could not be written` + (r.detail ? ` (${r.detail})` : "") + `; check the file's permissions and re-run`;
2047
+ case "unsupported-config-shape": return `its config could not be edited safely` + (r.detail ? ` (${r.detail})` : "") + `; fix the file or add the entry by hand`;
1261
2048
  default:
1262
2049
  if (r.commandError) return `\`${r.command}\` (${r.commandError})`;
1263
2050
  return "no config location on this platform";
@@ -1268,14 +2055,15 @@ function outcomeLine(o, dryRun) {
1268
2055
  const r = o.result;
1269
2056
  const note = o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "";
1270
2057
  const where = r.path ? ` → ${r.path}` : "";
2058
+ const backup = r.backupPath ? dryRun ? ` [original would be backed up to ${r.backupPath}]` : ` [original backed up to ${r.backupPath}]` : "";
1271
2059
  if (!r.ok) return `${o.label}: failed — ${failureText(o)}`;
1272
2060
  if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}`;
1273
2061
  if (r.command) {
1274
2062
  if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${note}`;
1275
2063
  return `${o.label} (${r.scope}): run \`${r.command}\`${note}`;
1276
2064
  }
1277
- if (dryRun) return `${o.label}: would update (${r.scope})${where} (dry run — no changes)${note}`;
1278
- if (r.wrote) return `${o.label}: installed (${r.scope})${where}${note}`;
2065
+ if (dryRun) return `${o.label}: would update (${r.scope})${where} (dry run — no changes)${backup}${note}`;
2066
+ if (r.wrote) return `${o.label}: installed (${r.scope})${where}${backup}${note}`;
1279
2067
  return `${o.label}: no change (${r.scope})${where}${note}`;
1280
2068
  }
1281
2069
  /** Render a report as a plain multi-line summary (used by the CLI + tests). */
package/dist/cli.js CHANGED
@@ -106,8 +106,8 @@ const mcpAddCommand = buildCommand({
106
106
  docs: {
107
107
  brief: "Add the Levr MCP server to installed AI clients",
108
108
  fullDescription: `Detect MCP-capable clients on this machine (Claude Desktop,
109
- Claude Code, Cursor, Windsurf, Zed) and write the Levr MCP server into each
110
- one's config. The entry is credential-free — the client opens a browser to
109
+ Claude Code, Cursor, Windsurf, Zed, Gemini CLI, Codex CLI, Grok Build,
110
+ Antigravity) and write the Levr MCP server into each one's config. The entry is credential-free — the client opens a browser to
111
111
  authorize with Levr the first time it connects.
112
112
 
113
113
  --scope decides where the entry lands:
@@ -115,8 +115,8 @@ authorize with Levr the first time it connects.
115
115
  project this repository, shared with everyone who checks it out
116
116
  local this repository, only you (Claude Code only)
117
117
 
118
- Not every client supports every scope — Claude Desktop and Windsurf are
119
- user-only. A client you name with --client fails if it cannot honor the
118
+ Not every client supports every scope — Claude Desktop, Windsurf, Codex CLI
119
+ and Antigravity are user-only. A client you name with --client fails if it cannot honor the
120
120
  scope you asked for; one picked up by --all or interactively falls back to
121
121
  the scope it does support, and the report says so.
122
122
 
@@ -175,7 +175,7 @@ Examples:
175
175
  aliases: { y: "yes" }
176
176
  },
177
177
  loader: async () => {
178
- const { mcpAddHandler } = await import("./addHandler-Brr4ndcR.js");
178
+ const { mcpAddHandler } = await import("./addHandler-BlZQkDtP.js");
179
179
  return mcpAddHandler;
180
180
  }
181
181
  });
@@ -213,7 +213,7 @@ Examples:
213
213
  aliases: { d: "device-code" }
214
214
  },
215
215
  loader: async () => {
216
- const { loginHandler } = await import("./loginHandler-B7ggggGI.js");
216
+ const { loginHandler } = await import("./loginHandler-YpxXFF91.js");
217
217
  return loginHandler;
218
218
  }
219
219
  });
@@ -252,7 +252,7 @@ Examples:
252
252
  },
253
253
  parameters: {},
254
254
  loader: async () => {
255
- const { statusHandler } = await import("./statusHandler-D5n1x-lz.js");
255
+ const { statusHandler } = await import("./statusHandler-d2tpzChM.js");
256
256
  return statusHandler;
257
257
  }
258
258
  });
@@ -364,7 +364,7 @@ Examples:
364
364
  }
365
365
  },
366
366
  loader: async () => {
367
- const { pushHandler } = await import("./pushHandler-C7s-Ki18.js");
367
+ const { pushHandler } = await import("./pushHandler-C_55ZQCc.js");
368
368
  return pushHandler;
369
369
  }
370
370
  });
@@ -484,7 +484,7 @@ Examples:
484
484
  }
485
485
  },
486
486
  loader: async () => {
487
- const { importHandler } = await import("./importHandler-C3w-uUQ_.js");
487
+ const { importHandler } = await import("./importHandler-BuhUIz4K.js");
488
488
  return importHandler;
489
489
  }
490
490
  });
@@ -505,7 +505,7 @@ Examples:
505
505
  },
506
506
  parameters: {},
507
507
  loader: async () => {
508
- const { listHandler } = await import("./listHandler-CKsZ1_8V.js");
508
+ const { listHandler } = await import("./listHandler-Dvfc4DRe.js");
509
509
  return listHandler;
510
510
  }
511
511
  });
@@ -538,7 +538,7 @@ Examples:
538
538
  flags: {}
539
539
  },
540
540
  loader: async () => {
541
- const { selectHandler } = await import("./selectHandler-CdQ4t9Zd.js");
541
+ const { selectHandler } = await import("./selectHandler-BAv7qh2N.js");
542
542
  return selectHandler;
543
543
  }
544
544
  });
@@ -562,7 +562,7 @@ Examples:
562
562
 
563
563
  //#endregion
564
564
  //#region package.json
565
- var version = "0.9.12";
565
+ var version = "0.9.14";
566
566
 
567
567
  //#endregion
568
568
  //#region src/app.ts
@@ -1,7 +1,7 @@
1
1
  import { getApiUrl, getTeamId } from "./env-CHeKHu5S.js";
2
- import { authGetProfileV1, configureClient, teamFindAllV1, testCaseImportCommitV1, testCaseImportPreviewV1 } from "./sdk-client-QtfUKoae.js";
2
+ import { authGetProfileV1, configureClient, teamFindAllV1, testCaseImportCommitV1, testCaseImportPreviewV1 } from "./sdk-client-6JKpUVMC.js";
3
3
  import "./workspace-store-DDOxnut1.js";
4
- import { resolveWorkspace } from "./resolve-workspace-febNMGo2.js";
4
+ import { resolveWorkspace } from "./resolve-workspace-CjhW-JrN.js";
5
5
  import "./token-refresh-Cu5RpkLJ.js";
6
6
  import { resolveToken } from "./resolve-token-DbQsmn03.js";
7
7
  import chalk from "chalk";
@@ -1,5 +1,5 @@
1
1
  import "./env-CHeKHu5S.js";
2
- import { authGetSitesV1, configureClient } from "./sdk-client-QtfUKoae.js";
2
+ import { authGetSitesV1, configureClient } from "./sdk-client-6JKpUVMC.js";
3
3
  import { loadWorkspace } from "./workspace-store-DDOxnut1.js";
4
4
  import "./token-refresh-Cu5RpkLJ.js";
5
5
  import { resolveToken } from "./resolve-token-DbQsmn03.js";
@@ -1,7 +1,7 @@
1
1
  import { CLI_CLIENT_ID, getApiUrl, getAuthUrl, setSessionApiUrl, writeCredentials } from "./env-CHeKHu5S.js";
2
- import { configureClient } from "./sdk-client-QtfUKoae.js";
2
+ import { configureClient } from "./sdk-client-6JKpUVMC.js";
3
3
  import "./workspace-store-DDOxnut1.js";
4
- import { autoSelectWorkspace } from "./resolve-workspace-febNMGo2.js";
4
+ import { autoSelectWorkspace } from "./resolve-workspace-CjhW-JrN.js";
5
5
  import chalk from "chalk";
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { createServer } from "node:http";
@@ -1,7 +1,7 @@
1
1
  import { getApiUrl, getAutomationSourceIdOverride, getSourceOverride, getTeamId } from "./env-CHeKHu5S.js";
2
- import { client, configureClient, uploadAutomationIngest, uploadImport } from "./sdk-client-QtfUKoae.js";
2
+ import { client, configureClient, uploadAutomationIngest, uploadImport } from "./sdk-client-6JKpUVMC.js";
3
3
  import "./workspace-store-DDOxnut1.js";
4
- import { resolveWorkspace } from "./resolve-workspace-febNMGo2.js";
4
+ import { resolveWorkspace } from "./resolve-workspace-CjhW-JrN.js";
5
5
  import "./token-refresh-Cu5RpkLJ.js";
6
6
  import { resolveToken } from "./resolve-token-DbQsmn03.js";
7
7
  import { readFileSync, statSync } from "node:fs";
@@ -1,4 +1,4 @@
1
- import { authGetSitesV1 } from "./sdk-client-QtfUKoae.js";
1
+ import { authGetSitesV1 } from "./sdk-client-6JKpUVMC.js";
2
2
  import { clearWorkspace, loadWorkspace, saveWorkspace } from "./workspace-store-DDOxnut1.js";
3
3
 
4
4
  //#region src/workspace/resolve-workspace.ts
@@ -2795,10 +2795,9 @@ const zCreateAutomationRunDto = z.object({
2795
2795
  "skipped",
2796
2796
  "pending",
2797
2797
  "todo",
2798
- "timed_out",
2799
- "interrupted",
2800
2798
  "unknown"
2801
2799
  ]).optional().describe(""),
2800
+ blocked_by_flaky: z.boolean().optional().describe(""),
2802
2801
  raw_status: z.string().nullable().optional().describe(""),
2803
2802
  total_tests: z.number().optional().describe(""),
2804
2803
  passed_count: z.number().optional().describe(""),
@@ -2850,10 +2849,9 @@ const zResponseAutomationRunDto = z.object({
2850
2849
  "skipped",
2851
2850
  "pending",
2852
2851
  "todo",
2853
- "timed_out",
2854
- "interrupted",
2855
2852
  "unknown"
2856
2853
  ]).optional().describe(""),
2854
+ blocked_by_flaky: z.boolean().optional().describe(""),
2857
2855
  raw_status: z.string().nullable().optional().describe(""),
2858
2856
  total_tests: z.number().optional().describe(""),
2859
2857
  passed_count: z.number().optional().describe(""),
@@ -2916,10 +2914,9 @@ const zUpdateAutomationRunDto = z.object({
2916
2914
  "skipped",
2917
2915
  "pending",
2918
2916
  "todo",
2919
- "timed_out",
2920
- "interrupted",
2921
2917
  "unknown"
2922
2918
  ]).optional().describe(""),
2919
+ blocked_by_flaky: z.boolean().optional().describe(""),
2923
2920
  raw_status: z.string().nullable().optional().describe(""),
2924
2921
  total_tests: z.number().optional().describe(""),
2925
2922
  passed_count: z.number().optional().describe(""),
@@ -3004,6 +3001,7 @@ const zAutomationRunFindAllV1Data = z.object({
3004
3001
  "filter.ended_at": z.array(z.string()).optional(),
3005
3002
  "filter.total_duration_ms": z.array(z.string()).optional(),
3006
3003
  "filter.is_complete": z.array(z.string()).optional(),
3004
+ "filter.blocked_by_flaky": z.array(z.string()).optional(),
3007
3005
  "filter.raw_status": z.array(z.string()).optional(),
3008
3006
  "filter.total_tests": z.array(z.string()).optional(),
3009
3007
  "filter.passed_count": z.array(z.string()).optional(),
@@ -4099,6 +4097,11 @@ const zCreateAutomationTestDto = z.object({
4099
4097
  "unknown"
4100
4098
  ]).nullable().optional().describe(""),
4101
4099
  flake_score: z.number().nullable().optional().describe(""),
4100
+ health_status: z.enum([
4101
+ "healthy",
4102
+ "flaky",
4103
+ "broken"
4104
+ ]).nullable().optional().describe(""),
4102
4105
  is_dormant: z.boolean().optional().describe(""),
4103
4106
  extra: z.unknown().optional().describe(""),
4104
4107
  team_id: z.string(),
@@ -4141,6 +4144,11 @@ const zResponseAutomationTestDto = z.object({
4141
4144
  "unknown"
4142
4145
  ]).nullable().optional().describe(""),
4143
4146
  flake_score: z.number().nullable().optional().describe(""),
4147
+ health_status: z.enum([
4148
+ "healthy",
4149
+ "flaky",
4150
+ "broken"
4151
+ ]).nullable().optional().describe(""),
4144
4152
  is_dormant: z.boolean().optional().describe(""),
4145
4153
  extra: z.unknown().optional().describe(""),
4146
4154
  team_id: z.string(),
@@ -4191,6 +4199,11 @@ const zUpdateAutomationTestDto = z.object({
4191
4199
  "unknown"
4192
4200
  ]).nullable().optional().describe(""),
4193
4201
  flake_score: z.number().nullable().optional().describe(""),
4202
+ health_status: z.enum([
4203
+ "healthy",
4204
+ "flaky",
4205
+ "broken"
4206
+ ]).nullable().optional().describe(""),
4194
4207
  is_dormant: z.boolean().optional().describe(""),
4195
4208
  extra: z.unknown().optional().describe(""),
4196
4209
  team_id: z.string().optional(),
@@ -1,5 +1,5 @@
1
1
  import "./env-CHeKHu5S.js";
2
- import { authGetSitesV1, configureClient } from "./sdk-client-QtfUKoae.js";
2
+ import { authGetSitesV1, configureClient } from "./sdk-client-6JKpUVMC.js";
3
3
  import { saveWorkspace } from "./workspace-store-DDOxnut1.js";
4
4
  import "./token-refresh-Cu5RpkLJ.js";
5
5
  import { resolveToken } from "./resolve-token-DbQsmn03.js";
@@ -1,5 +1,5 @@
1
1
  import { getApiUrl, getPatToken, readCredentials } from "./env-CHeKHu5S.js";
2
- import { authGetProfileV1, configureClient } from "./sdk-client-QtfUKoae.js";
2
+ import { authGetProfileV1, configureClient } from "./sdk-client-6JKpUVMC.js";
3
3
  import { isTokenExpired } from "./token-refresh-Cu5RpkLJ.js";
4
4
  import chalk from "chalk";
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levr-one/cli",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
4
4
  "description": "The command-line interface for Levr",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "jsonc-parser": "^3.3.1",
32
32
  "open": "^10.1.0",
33
33
  "ora": "^9.0.0",
34
+ "smol-toml": "^1.8.0",
34
35
  "zod": "^3.25.0"
35
36
  },
36
37
  "devDependencies": {