@levr-one/cli 0.11.9 → 0.11.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,8 +4,9 @@ The command-line interface for [Levr](https://www.levr.one). The binary is
4
4
  `levr`, and it does three jobs:
5
5
 
6
6
  - **Connect your AI tools** — `levr mcp add` wires the Levr MCP server into
7
- the AI clients on your machine (Claude Desktop, Claude Code, Cursor,
8
- Windsurf, Zed) with one command.
7
+ the AI clients on your machine (Claude Desktop, Claude Code, Codex CLI,
8
+ Grok Build, Antigravity, Gemini CLI, Cursor, Windsurf, Zed, VS Code) with
9
+ one command.
9
10
  - **Push test results** — `levr push` uploads results from any terminal or CI
10
11
  pipeline.
11
12
  - **Import test cases** — `levr import` brings existing cases in from CSV,
@@ -91,25 +92,25 @@ the server's address. No token or secret is stored; your client opens the
91
92
  browser to authorize with Levr on its first connection. After a run, restart
92
93
  the client(s) and authorize once.
93
94
 
94
- Clients that speak HTTP MCP themselves (Claude Code, VS Code, Gemini CLI,
95
- Codex CLI, Grok Build, Antigravity) connect to the URL directly. Claude
95
+ Clients that speak HTTP MCP themselves (Claude Code, Codex CLI, Grok Build,
96
+ Antigravity, Gemini CLI, VS Code) connect to the URL directly. Claude
96
97
  Desktop, Cursor, Windsurf and Zed get an entry that runs
97
98
  `npx -y mcp-remote <url>`, which connects on their behalf.
98
99
 
99
100
  **Supported clients:**
100
101
 
101
- | Client | How it's configured |
102
- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
103
- | Claude Desktop | config file (`claude_desktop_config.json`) |
104
- | Claude Code | runs `claude mcp add --transport http --scope user levr <url>` for you (prints it instead when `claude` is not on your `PATH`) |
105
- | Cursor | config file (`~/.cursor/mcp.json`) |
106
- | Windsurf | config file (`~/.codeium/windsurf/mcp_config.json`) |
107
- | Zed | config file (`settings.json`, `context_servers`) |
108
- | VS Code | config file (`Code/User/mcp.json` in your VS Code settings folder; `.vscode/mcp.json` with `--scope project`) |
109
- | Gemini CLI | config file (`~/.gemini/settings.json`) |
110
- | Codex CLI | config file (`~/.codex/config.toml`, TOML) |
111
- | Grok Build | config file (`~/.grok/config.toml`, TOML) |
112
- | Antigravity (CLI + IDE) | config file (`~/.gemini/config/mcp_config.json`) |
102
+ | Client | How it's configured |
103
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
+ | Claude Desktop | config file (`claude_desktop_config.json`) |
105
+ | Claude Code | runs `claude mcp add --transport http --scope user levr <url>` for you (prints it instead when `claude` is not on your `PATH`; `--scope project` writes `.mcp.json` instead) |
106
+ | Codex CLI | config file (`~/.codex/config.toml`, TOML; `.codex/config.toml` with `--scope project`, which Codex loads once it trusts the repo) |
107
+ | Grok Build | config file (`~/.grok/config.toml`, TOML; `.grok/config.toml` with `--scope project`) |
108
+ | Antigravity (CLI + IDE) | config file (`~/.gemini/config/mcp_config.json`; `.agents/mcp_config.json` with `--scope project`) |
109
+ | Gemini CLI | config file (`~/.gemini/settings.json`; `.gemini/settings.json` with `--scope project`) |
110
+ | Cursor | config file (`~/.cursor/mcp.json`; `.cursor/mcp.json` with `--scope project`) |
111
+ | Windsurf | config file (`~/.codeium/windsurf/mcp_config.json`) |
112
+ | Zed | config file (`settings.json`, `context_servers`; `.zed/settings.json` with `--scope project`) |
113
+ | VS Code | config file (`Code/User/mcp.json` in your VS Code settings folder; `.vscode/mcp.json` with `--scope project`) |
113
114
 
114
115
  Config edits are **safe and repeatable**: existing MCP servers and comments
115
116
  in your config files are preserved, and re-running reports "already set up".
@@ -10,7 +10,7 @@ import { basename, delimiter, dirname, join, resolve } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
12
12
  import { TomlError, parse as parse$1 } from "smol-toml";
13
- import { execFileSync } from "node:child_process";
13
+ import { execFileSync, spawnSync } from "node:child_process";
14
14
 
15
15
  //#region ../mcp-harnesses/dist/format/toml-render.js
16
16
  /**
@@ -105,6 +105,12 @@ function renderTomlBodyLines(value, pathPrefix) {
105
105
 
106
106
  //#endregion
107
107
  //#region ../mcp-harnesses/dist/catalog.js
108
+ /**
109
+ * Sentinel for {@link AutoApproveDescriptor.file}: the permission key lives in
110
+ * the same file this scope's MCP server entry is written to (Zed keeps both in
111
+ * `settings.json`), so the path is resolved exactly as the install resolves it.
112
+ */
113
+ const SAME_FILE_AS_MCP_CONFIG = "<mcp-config-file>";
108
114
  /** The `{ url, type: "http" }` shape Claude Code and Gemini CLI write. */
109
115
  const DEFAULT_NATIVE_HTTP_ENTRY = {
110
116
  urlKey: "url",
@@ -163,7 +169,11 @@ const CLAUDE_CODE_LOCATIONS = [
163
169
  }
164
170
  ];
165
171
  /**
166
- * The catalog. Order is presentation order (most common first).
172
+ * The catalog. Order is presentation order: the agent CLIs first (Claude
173
+ * Desktop, Claude Code, Codex CLI, Grok Build, Antigravity, Gemini CLI), then
174
+ * the IDEs (Cursor, Windsurf, Zed, VS Code) — ENG-7100. Every surface that
175
+ * lists clients iterates this array unsorted, so this is the one place order
176
+ * is decided; `catalog.test.ts` pins it.
167
177
  *
168
178
  * Every entry is installable, and there is no deferral flag: the old
169
179
  * coming-soon flag was retired in ENG-5558 once its last member left. Codex
@@ -237,6 +247,20 @@ const HARNESSES = [
237
247
  verifiedVersion: UNVERIFIED_LEGACY,
238
248
  verifiedOn: UNVERIFIED_LEGACY,
239
249
  configDirEnv: "CLAUDE_CONFIG_DIR",
250
+ autoApprove: {
251
+ file: {
252
+ user: "~/.claude/settings.json",
253
+ project: ".claude/settings.json",
254
+ local: ".claude/settings.local.json"
255
+ },
256
+ format: "json",
257
+ keyPath: ["permissions", "allow"],
258
+ shape: "allow-list",
259
+ entry: (serverName, tool) => `mcp__${serverName}__${tool}`,
260
+ configDirReplaces: "~/.claude",
261
+ docsUrl: "https://code.claude.com/docs/en/permissions",
262
+ verifiedOn: "2026-10-09"
263
+ },
240
264
  detectSignals: [
241
265
  {
242
266
  platform: "darwin",
@@ -285,30 +309,30 @@ const HARNESSES = [
285
309
  ]
286
310
  },
287
311
  {
288
- id: "cursor",
289
- label: "Cursor",
290
- matchers: ["cursor"],
291
- serverPropertyName: "mcpServers",
292
- transport: "mcp-remote",
293
- docsUrl: "https://cursor.com/docs/mcp",
294
- verifiedVersion: UNVERIFIED_LEGACY,
295
- verifiedOn: UNVERIFIED_LEGACY,
312
+ id: "codex",
313
+ label: "Codex CLI",
314
+ matchers: ["codex"],
315
+ serverPropertyName: "mcp_servers",
316
+ transport: "native-http",
317
+ nativeHttpEntry: { urlKey: "url" },
318
+ configFormat: "toml",
319
+ docsUrl: "https://github.com/openai/codex",
320
+ verifiedVersion: "0.162.1",
321
+ verifiedOn: "2026-10-16",
322
+ configDirEnv: "CODEX_HOME",
323
+ configDirReplaces: "~/.codex",
296
324
  detectSignals: [
297
325
  {
298
326
  platform: "darwin",
299
- signals: [
300
- "~/.cursor",
301
- "which:cursor",
302
- "/Applications/Cursor.app"
303
- ]
327
+ signals: ["~/.codex", "which:codex"]
304
328
  },
305
329
  {
306
- platform: "win32",
307
- signals: ["~/.cursor", "which:cursor"]
330
+ platform: "linux",
331
+ signals: ["~/.codex", "which:codex"]
308
332
  },
309
333
  {
310
- platform: "linux",
311
- signals: ["~/.cursor", "which:cursor"]
334
+ platform: "win32",
335
+ signals: ["~/.codex", "which:codex"]
312
336
  }
313
337
  ],
314
338
  scopes: [{
@@ -317,48 +341,50 @@ const HARNESSES = [
317
341
  locations: [
318
342
  {
319
343
  platform: "darwin",
320
- configPath: "~/.cursor/mcp.json"
344
+ configPath: "~/.codex/config.toml"
321
345
  },
322
346
  {
323
- platform: "win32",
324
- configPath: "~/.cursor/mcp.json"
347
+ platform: "linux",
348
+ configPath: "~/.codex/config.toml"
325
349
  },
326
350
  {
327
- platform: "linux",
328
- configPath: "~/.cursor/mcp.json"
351
+ platform: "win32",
352
+ configPath: "~/.codex/config.toml"
329
353
  }
330
354
  ]
331
355
  }, {
332
356
  scope: "project",
333
357
  installKind: "config-file",
334
- projectPath: ".cursor/mcp.json"
358
+ projectPath: ".codex/config.toml",
359
+ note: "Codex loads .codex/config.toml only once it trusts this repo: accept the trust prompt on first launch, or set trust_level = \"trusted\" under [projects.\"<repo path>\"] in your Codex config (~/.codex/config.toml, or $CODEX_HOME/config.toml when CODEX_HOME is set)"
335
360
  }]
336
361
  },
337
362
  {
338
- id: "windsurf",
339
- label: "Windsurf",
340
- matchers: ["windsurf", "codeium"],
341
- serverPropertyName: "mcpServers",
342
- transport: "mcp-remote",
343
- docsUrl: "https://docs.windsurf.com/windsurf/mcp",
344
- verifiedVersion: UNVERIFIED_LEGACY,
345
- verifiedOn: UNVERIFIED_LEGACY,
363
+ id: "grok",
364
+ label: "Grok Build",
365
+ matchers: ["grok build", "grok-build"],
366
+ serverPropertyName: "mcp_servers",
367
+ transport: "native-http",
368
+ nativeHttpEntry: {
369
+ urlKey: "url",
370
+ constants: { enabled: true }
371
+ },
372
+ configFormat: "toml",
373
+ docsUrl: "https://docs.x.ai/build/overview",
374
+ verifiedVersion: "1.0.34",
375
+ verifiedOn: "2026-09-17",
346
376
  detectSignals: [
347
377
  {
348
378
  platform: "darwin",
349
- signals: [
350
- "~/.codeium/windsurf",
351
- "/Applications/Windsurf.app",
352
- "which:windsurf"
353
- ]
379
+ signals: ["~/.grok/config.toml", "~/.grok/bin/grok"]
354
380
  },
355
381
  {
356
- platform: "win32",
357
- signals: ["~/.codeium/windsurf", "which:windsurf"]
382
+ platform: "linux",
383
+ signals: ["~/.grok/config.toml", "~/.grok/bin/grok"]
358
384
  },
359
385
  {
360
- platform: "linux",
361
- signals: ["~/.codeium/windsurf", "which:windsurf"]
386
+ platform: "win32",
387
+ signals: ["~/.grok/config.toml", "~/.grok/bin/grok.exe"]
362
388
  }
363
389
  ],
364
390
  scopes: [{
@@ -367,44 +393,57 @@ const HARNESSES = [
367
393
  locations: [
368
394
  {
369
395
  platform: "darwin",
370
- configPath: "~/.codeium/windsurf/mcp_config.json"
396
+ configPath: "~/.grok/config.toml"
371
397
  },
372
398
  {
373
- platform: "win32",
374
- configPath: "~/.codeium/windsurf/mcp_config.json"
399
+ platform: "linux",
400
+ configPath: "~/.grok/config.toml"
375
401
  },
376
402
  {
377
- platform: "linux",
378
- configPath: "~/.codeium/windsurf/mcp_config.json"
403
+ platform: "win32",
404
+ configPath: "~/.grok/config.toml"
379
405
  }
380
406
  ]
407
+ }, {
408
+ scope: "project",
409
+ installKind: "config-file",
410
+ projectPath: ".grok/config.toml"
381
411
  }]
382
412
  },
383
413
  {
384
- id: "zed",
385
- label: "Zed",
386
- matchers: ["zed"],
387
- serverPropertyName: "context_servers",
388
- transport: "mcp-remote",
389
- docsUrl: "https://zed.dev/docs/ai/mcp",
390
- verifiedVersion: UNVERIFIED_LEGACY,
391
- verifiedOn: UNVERIFIED_LEGACY,
414
+ id: "antigravity",
415
+ label: "Antigravity",
416
+ matchers: ["antigravity"],
417
+ serverPropertyName: "mcpServers",
418
+ transport: "native-http",
419
+ nativeHttpEntry: {
420
+ urlKey: "serverUrl",
421
+ constants: { disabled: false }
422
+ },
423
+ docsUrl: "https://antigravity.google/docs/mcp",
424
+ verifiedVersion: "1.4.0",
425
+ verifiedOn: "2026-10-16",
392
426
  detectSignals: [
393
427
  {
394
428
  platform: "darwin",
395
429
  signals: [
396
- "~/.config/zed",
397
- "/Applications/Zed.app",
398
- "which:zed"
430
+ "which:agy",
431
+ "~/.local/bin/agy",
432
+ "/Applications/Antigravity IDE.app",
433
+ "~/.gemini/config/mcp_config.json"
399
434
  ]
400
435
  },
401
436
  {
402
437
  platform: "linux",
403
- signals: ["~/.config/zed", "which:zed"]
438
+ signals: [
439
+ "which:agy",
440
+ "~/.local/bin/agy",
441
+ "~/.gemini/config/mcp_config.json"
442
+ ]
404
443
  },
405
444
  {
406
445
  platform: "win32",
407
- signals: ["~/AppData/Roaming/Zed"]
446
+ signals: ["which:agy", "~/.gemini/config/mcp_config.json"]
408
447
  }
409
448
  ],
410
449
  scopes: [{
@@ -413,21 +452,21 @@ const HARNESSES = [
413
452
  locations: [
414
453
  {
415
454
  platform: "darwin",
416
- configPath: "~/.config/zed/settings.json"
455
+ configPath: "~/.gemini/config/mcp_config.json"
417
456
  },
418
457
  {
419
458
  platform: "linux",
420
- configPath: "~/.config/zed/settings.json"
459
+ configPath: "~/.gemini/config/mcp_config.json"
421
460
  },
422
461
  {
423
462
  platform: "win32",
424
- configPath: "~/AppData/Roaming/Zed/settings.json"
463
+ configPath: "~/.gemini/config/mcp_config.json"
425
464
  }
426
465
  ]
427
466
  }, {
428
467
  scope: "project",
429
468
  installKind: "config-file",
430
- projectPath: ".zed/settings.json"
469
+ projectPath: ".agents/mcp_config.json"
431
470
  }]
432
471
  },
433
472
  {
@@ -477,31 +516,44 @@ const HARNESSES = [
477
516
  }]
478
517
  },
479
518
  {
480
- id: "grok",
481
- label: "Grok Build",
482
- matchers: ["grok build", "grok-build"],
483
- serverPropertyName: "mcp_servers",
484
- transport: "native-http",
485
- nativeHttpEntry: {
486
- urlKey: "url",
487
- constants: { enabled: true }
519
+ id: "cursor",
520
+ label: "Cursor",
521
+ matchers: ["cursor"],
522
+ serverPropertyName: "mcpServers",
523
+ transport: "mcp-remote",
524
+ docsUrl: "https://cursor.com/docs/mcp",
525
+ verifiedVersion: UNVERIFIED_LEGACY,
526
+ verifiedOn: UNVERIFIED_LEGACY,
527
+ autoApprove: {
528
+ file: {
529
+ user: "~/.cursor/permissions.json",
530
+ project: ".cursor/permissions.json"
531
+ },
532
+ format: "json",
533
+ keyPath: ["mcpAllowlist"],
534
+ shape: "allow-list",
535
+ entry: (serverName, tool) => `${serverName}:${tool}`,
536
+ appendOnlyToExistingKey: true,
537
+ caveat: "Cursor: Levr only adds to an mcpAllowlist this file already has. It never creates one, because once mcpAllowlist exists here it replaces the MCP allowlist kept in Cursor Settings. The entries only take effect when Run Mode in Cursor Settings uses an allowlist.",
538
+ docsUrl: "https://cursor.com/docs/reference/permissions",
539
+ verifiedOn: "2026-10-09"
488
540
  },
489
- configFormat: "toml",
490
- docsUrl: "https://docs.x.ai/build/overview",
491
- verifiedVersion: "1.0.34",
492
- verifiedOn: "2026-09-17",
493
541
  detectSignals: [
494
542
  {
495
543
  platform: "darwin",
496
- signals: ["~/.grok/config.toml", "~/.grok/bin/grok"]
544
+ signals: [
545
+ "~/.cursor",
546
+ "which:cursor",
547
+ "/Applications/Cursor.app"
548
+ ]
497
549
  },
498
550
  {
499
- platform: "linux",
500
- signals: ["~/.grok/config.toml", "~/.grok/bin/grok"]
551
+ platform: "win32",
552
+ signals: ["~/.cursor", "which:cursor"]
501
553
  },
502
554
  {
503
- platform: "win32",
504
- signals: ["~/.grok/config.toml", "~/.grok/bin/grok.exe"]
555
+ platform: "linux",
556
+ signals: ["~/.cursor", "which:cursor"]
505
557
  }
506
558
  ],
507
559
  scopes: [{
@@ -510,57 +562,109 @@ const HARNESSES = [
510
562
  locations: [
511
563
  {
512
564
  platform: "darwin",
513
- configPath: "~/.grok/config.toml"
565
+ configPath: "~/.cursor/mcp.json"
514
566
  },
515
567
  {
516
- platform: "linux",
517
- configPath: "~/.grok/config.toml"
568
+ platform: "win32",
569
+ configPath: "~/.cursor/mcp.json"
518
570
  },
519
571
  {
520
- platform: "win32",
521
- configPath: "~/.grok/config.toml"
572
+ platform: "linux",
573
+ configPath: "~/.cursor/mcp.json"
522
574
  }
523
575
  ]
524
576
  }, {
525
577
  scope: "project",
526
578
  installKind: "config-file",
527
- projectPath: ".grok/config.toml"
579
+ projectPath: ".cursor/mcp.json"
528
580
  }]
529
581
  },
530
582
  {
531
- id: "antigravity",
532
- label: "Antigravity",
533
- matchers: ["antigravity"],
583
+ id: "windsurf",
584
+ label: "Windsurf",
585
+ matchers: ["windsurf", "codeium"],
534
586
  serverPropertyName: "mcpServers",
535
- transport: "native-http",
536
- nativeHttpEntry: {
537
- urlKey: "serverUrl",
538
- constants: { disabled: false }
539
- },
540
- docsUrl: "https://antigravity.google/docs/mcp",
541
- verifiedVersion: "1.2.5",
542
- verifiedOn: "2026-09-17",
587
+ transport: "mcp-remote",
588
+ docsUrl: "https://docs.windsurf.com/windsurf/mcp",
589
+ verifiedVersion: UNVERIFIED_LEGACY,
590
+ verifiedOn: UNVERIFIED_LEGACY,
543
591
  detectSignals: [
544
592
  {
545
593
  platform: "darwin",
546
594
  signals: [
547
- "which:agy",
548
- "~/.local/bin/agy",
549
- "/Applications/Antigravity IDE.app",
550
- "~/.gemini/config/mcp_config.json"
595
+ "~/.codeium/windsurf",
596
+ "/Applications/Windsurf.app",
597
+ "which:windsurf"
551
598
  ]
552
599
  },
600
+ {
601
+ platform: "win32",
602
+ signals: ["~/.codeium/windsurf", "which:windsurf"]
603
+ },
553
604
  {
554
605
  platform: "linux",
606
+ signals: ["~/.codeium/windsurf", "which:windsurf"]
607
+ }
608
+ ],
609
+ scopes: [{
610
+ scope: "user",
611
+ installKind: "config-file",
612
+ locations: [
613
+ {
614
+ platform: "darwin",
615
+ configPath: "~/.codeium/windsurf/mcp_config.json"
616
+ },
617
+ {
618
+ platform: "win32",
619
+ configPath: "~/.codeium/windsurf/mcp_config.json"
620
+ },
621
+ {
622
+ platform: "linux",
623
+ configPath: "~/.codeium/windsurf/mcp_config.json"
624
+ }
625
+ ]
626
+ }]
627
+ },
628
+ {
629
+ id: "zed",
630
+ label: "Zed",
631
+ matchers: ["zed"],
632
+ serverPropertyName: "context_servers",
633
+ transport: "mcp-remote",
634
+ docsUrl: "https://zed.dev/docs/ai/mcp",
635
+ verifiedVersion: UNVERIFIED_LEGACY,
636
+ verifiedOn: UNVERIFIED_LEGACY,
637
+ autoApprove: {
638
+ file: { user: SAME_FILE_AS_MCP_CONFIG },
639
+ format: "json",
640
+ keyPath: [
641
+ "agent",
642
+ "tool_permissions",
643
+ "tools"
644
+ ],
645
+ shape: "tool-map",
646
+ entry: (serverName, tool) => `mcp:${serverName}:${tool}`,
647
+ toolValue: { default: "allow" },
648
+ caveat: "Zed: per-tool permissions need Zed v0.224.0 or later.",
649
+ docsUrl: "https://zed.dev/docs/ai/tool-permissions",
650
+ verifiedOn: "2026-10-09"
651
+ },
652
+ detectSignals: [
653
+ {
654
+ platform: "darwin",
555
655
  signals: [
556
- "which:agy",
557
- "~/.local/bin/agy",
558
- "~/.gemini/config/mcp_config.json"
656
+ "~/.config/zed",
657
+ "/Applications/Zed.app",
658
+ "which:zed"
559
659
  ]
560
660
  },
661
+ {
662
+ platform: "linux",
663
+ signals: ["~/.config/zed", "which:zed"]
664
+ },
561
665
  {
562
666
  platform: "win32",
563
- signals: ["which:agy", "~/.gemini/config/mcp_config.json"]
667
+ signals: ["~/AppData/Roaming/Zed"]
564
668
  }
565
669
  ],
566
670
  scopes: [{
@@ -569,17 +673,21 @@ const HARNESSES = [
569
673
  locations: [
570
674
  {
571
675
  platform: "darwin",
572
- configPath: "~/.gemini/config/mcp_config.json"
676
+ configPath: "~/.config/zed/settings.json"
573
677
  },
574
678
  {
575
679
  platform: "linux",
576
- configPath: "~/.gemini/config/mcp_config.json"
680
+ configPath: "~/.config/zed/settings.json"
577
681
  },
578
682
  {
579
683
  platform: "win32",
580
- configPath: "~/.gemini/config/mcp_config.json"
684
+ configPath: "~/AppData/Roaming/Zed/settings.json"
581
685
  }
582
686
  ]
687
+ }, {
688
+ scope: "project",
689
+ installKind: "config-file",
690
+ projectPath: ".zed/settings.json"
583
691
  }]
584
692
  },
585
693
  {
@@ -631,52 +739,6 @@ const HARNESSES = [
631
739
  installKind: "config-file",
632
740
  projectPath: ".vscode/mcp.json"
633
741
  }]
634
- },
635
- {
636
- id: "codex",
637
- label: "Codex CLI",
638
- matchers: ["codex"],
639
- serverPropertyName: "mcp_servers",
640
- transport: "native-http",
641
- nativeHttpEntry: { urlKey: "url" },
642
- configFormat: "toml",
643
- docsUrl: "https://github.com/openai/codex",
644
- verifiedVersion: "0.154.0",
645
- verifiedOn: "2026-09-17",
646
- configDirEnv: "CODEX_HOME",
647
- configDirReplaces: "~/.codex",
648
- detectSignals: [
649
- {
650
- platform: "darwin",
651
- signals: ["~/.codex", "which:codex"]
652
- },
653
- {
654
- platform: "linux",
655
- signals: ["~/.codex", "which:codex"]
656
- },
657
- {
658
- platform: "win32",
659
- signals: ["~/.codex", "which:codex"]
660
- }
661
- ],
662
- scopes: [{
663
- scope: "user",
664
- installKind: "config-file",
665
- locations: [
666
- {
667
- platform: "darwin",
668
- configPath: "~/.codex/config.toml"
669
- },
670
- {
671
- platform: "linux",
672
- configPath: "~/.codex/config.toml"
673
- },
674
- {
675
- platform: "win32",
676
- configPath: "~/.codex/config.toml"
677
- }
678
- ]
679
- }]
680
742
  }
681
743
  ];
682
744
  /** Look up a harness by id. Returns `undefined` for unknown ids. */
@@ -810,6 +872,76 @@ function besideName(serverName, url) {
810
872
  return name.length <= BESIDE_NAME_MAX && isValidEntryName(name) ? name : void 0;
811
873
  }
812
874
 
875
+ //#endregion
876
+ //#region ../mcp-harnesses/dist/levr-tool-allowlist.js
877
+ /**
878
+ * The Levr MCP tools `levr mcp add` may offer to pre-approve in a harness's
879
+ * own permission file (ENG-6392, plan ENG-6341 §D9).
880
+ *
881
+ * SINGLE SOURCE. Every harness auto-approve descriptor in `catalog.ts`
882
+ * renders its entries from this list, and the backender parity spec
883
+ * (`apps/backender/src/mcp/levr-tool-allowlist.parity.spec.ts`) asserts that
884
+ * every name here is a public tool and none is in
885
+ * `McpToolAdapterService.DESTRUCTIVE_TOOL_NAMES`.
886
+ *
887
+ * Rules for this list:
888
+ * - Only the non-destructive tools a first task actually calls. A tool that
889
+ * can delete, merge away or permanently unlink anything never belongs here.
890
+ * - Nor does a tool whose PURPOSE is to waive a check (waive_gate): see
891
+ * {@link LEVR_NEVER_PREAPPROVED_TOOLS}.
892
+ * - `deploy_gates` and `transition_issue` ARE listed (user decision
893
+ * 2026-10-15: neither is destructive, and the first task must run without
894
+ * permission prompts). deploy_gates can also change or withdraw a gate,
895
+ * and transition_issue can waive a required gate through
896
+ * `verification_override`. The installer's disclosure says so rather than
897
+ * promising otherwise, and every such change is recorded on the gate (code
898
+ * review F-027). The disclosure must stay literally true for every entry.
899
+ * - One entry per tool, by its exact registered name. Never a server-wide
900
+ * entry and never a glob: a server-wide grant would also pre-approve the
901
+ * destructive tools the server exposes.
902
+ *
903
+ * Isomorphic and import-free, like the catalog, so the client SPA and the
904
+ * backender can both read it.
905
+ */
906
+ /** The tools offered for pre-approval, in the order a first task uses them. */
907
+ const LEVR_PREAPPROVED_TOOLS = [
908
+ "get_context",
909
+ "start_onboarding",
910
+ "get_onboarding_step",
911
+ "get_issue",
912
+ "get_issues",
913
+ "search_issues",
914
+ "get_issue_comments",
915
+ "add_issue_comment",
916
+ "deploy_gates",
917
+ "verify_deliverable_gates",
918
+ "report_gate_results",
919
+ "get_gate_status",
920
+ "transition_issue",
921
+ "get_practice_app"
922
+ ];
923
+ /**
924
+ * A plain description of what each tool does, printed next to its entry in
925
+ * the installer's disclosure (plan R1-23) so the user can judge each line
926
+ * rather than a list of opaque identifiers.
927
+ */
928
+ const LEVR_PREAPPROVED_TOOL_DESCRIPTIONS = {
929
+ get_context: "reads your workspace, teams and projects",
930
+ start_onboarding: "starts a guided first task",
931
+ get_onboarding_step: "reads the current step of the guided first task",
932
+ get_issue: "reads one task",
933
+ get_issues: "lists tasks",
934
+ search_issues: "searches tasks",
935
+ get_issue_comments: "reads a task's comments",
936
+ add_issue_comment: "adds a comment to a task",
937
+ deploy_gates: "writes the task's gates; it can also change or withdraw a gate (recorded)",
938
+ transition_issue: "moves a task to another status; Done is refused while a required check fails, unless your agent waives that check (the waiver is recorded)",
939
+ verify_deliverable_gates: "runs the task's checklist",
940
+ report_gate_results: "records the checklist results your agent ran",
941
+ get_gate_status: "reads the task's checklist results",
942
+ get_practice_app: "reads the sample practice app"
943
+ };
944
+
813
945
  //#endregion
814
946
  //#region ../mcp-harnesses/dist/node/paths.js
815
947
  function defaultEnv() {
@@ -1212,6 +1344,27 @@ const jsoncAdapter = {
1212
1344
  },
1213
1345
  removeAt(text, path$1) {
1214
1346
  return edit(text, path$1, void 0);
1347
+ },
1348
+ appendToArray(text, path$1, values) {
1349
+ const doc = parseDocument$1(text);
1350
+ if (!doc.ok) throw new UnsupportedConfigShapeError(doc.detail);
1351
+ const current = getAtPath(doc.root, path$1);
1352
+ if (!Array.isArray(current)) throw new UnsupportedConfigShapeError(`${path$1.join(".")} is not an array — refusing to restructure it`);
1353
+ let next = text;
1354
+ let index = current.length;
1355
+ for (const value of values) {
1356
+ try {
1357
+ next = applyEdits(next, modify(next, [...path$1, index], value, {
1358
+ formattingOptions: FORMAT,
1359
+ isArrayInsertion: true
1360
+ }));
1361
+ } catch (err) {
1362
+ const message = err instanceof Error ? err.message : String(err);
1363
+ throw new UnsupportedConfigShapeError(`cannot append to ${path$1.join(".")}: ${message}`);
1364
+ }
1365
+ index++;
1366
+ }
1367
+ return next;
1215
1368
  }
1216
1369
  };
1217
1370
  /** Shared by set and remove: jsonc-parser removes a key when `value` is `undefined`. */
@@ -2104,6 +2257,207 @@ function writeConfigAtomic(path$1, text) {
2104
2257
  throw err;
2105
2258
  }
2106
2259
  }
2260
+ /** Resolve the descriptor's permission file for a scope, or why it cannot be. */
2261
+ function resolvePermissionFile(harness, desc, scope, env) {
2262
+ const file = desc.file[scope];
2263
+ if (!file || !scopeDef(harness, scope)) return { reason: "unsupported-scope" };
2264
+ if (file === SAME_FILE_AS_MCP_CONFIG) {
2265
+ const target = resolveTarget(harness, env, scope);
2266
+ if ("reason" in target) return { reason: target.reason === "not-a-repo" || target.reason === "no-location" ? target.reason : "unsupported-scope" };
2267
+ return target;
2268
+ }
2269
+ if (file.startsWith("~")) {
2270
+ if (harness.configDirEnv && desc.configDirReplaces) return { path: expandConfigPath(file, {
2271
+ ...harness,
2272
+ configDirReplaces: desc.configDirReplaces
2273
+ }, env) };
2274
+ return { path: expandTilde(file, env.homedir) };
2275
+ }
2276
+ const root = resolveProjectRoot(env);
2277
+ if (!root.isRepo) return { reason: "not-a-repo" };
2278
+ return {
2279
+ path: join(root.root, ...file.split("/")),
2280
+ repoRoot: root.root
2281
+ };
2282
+ }
2283
+ /**
2284
+ * Whether git ignores `file` inside the work tree at `repoRoot`: `true` /
2285
+ * `false` from `git check-ignore`, `undefined` when git cannot say (not
2286
+ * installed, not a work tree). Never throws. Git runs in the injected machine
2287
+ * environment — `HOME` is `env.homedir`, so the user's global excludes file
2288
+ * counts and a test's temp home isolates it — and the `GIT_*` per-process
2289
+ * variables are dropped so a parent git (a hook) cannot redirect the check to
2290
+ * another repository.
2291
+ */
2292
+ function isGitIgnored(env, repoRoot, file) {
2293
+ const childEnv = {
2294
+ ...env.vars ?? {},
2295
+ HOME: env.homedir,
2296
+ PATH: env.pathVar || process.env.PATH
2297
+ };
2298
+ for (const key of [
2299
+ "GIT_DIR",
2300
+ "GIT_WORK_TREE",
2301
+ "GIT_INDEX_FILE",
2302
+ "GIT_COMMON_DIR"
2303
+ ]) delete childEnv[key];
2304
+ try {
2305
+ const r = spawnSync("git", [
2306
+ "check-ignore",
2307
+ "-q",
2308
+ "--",
2309
+ file
2310
+ ], {
2311
+ cwd: repoRoot,
2312
+ env: childEnv,
2313
+ stdio: "ignore",
2314
+ timeout: 5e3
2315
+ });
2316
+ if (r.status === 0) return true;
2317
+ if (r.status === 1) return false;
2318
+ return;
2319
+ } catch {
2320
+ return;
2321
+ }
2322
+ }
2323
+ function permissionAdapter(desc) {
2324
+ return desc.format === "toml" ? tomlAdapter : jsoncAdapter;
2325
+ }
2326
+ /**
2327
+ * Pre-approve the non-destructive Levr tools in a harness's own permission
2328
+ * file, so its agent can call them without asking each time.
2329
+ *
2330
+ * MERGE-ONLY and NON-DESTRUCTIVE: entries are appended to what the file
2331
+ * already holds; an existing entry is reported in `alreadyPresent` and never
2332
+ * duplicated; a `tool-map` key the user already set to something else is
2333
+ * reported in `conflicting` and left alone. Comments survive (the edit is an
2334
+ * in-place text patch, never a re-serialize), and the write is atomic
2335
+ * ({@link writeConfigAtomic}: a sibling temp file, then rename), so an
2336
+ * interrupted write leaves the original file intact.
2337
+ *
2338
+ * Error direction: fail_closed. An existing file that cannot be read or
2339
+ * parsed, or that holds the key in a shape we will not restructure (e.g.
2340
+ * `permissions.allow` is a string), aborts with
2341
+ * `unsupported-config-shape` and writes NOTHING — never a clobber. Every
2342
+ * failure is returned, not thrown, so the caller can report "permissions not
2343
+ * added" and carry on (the MCP install itself already succeeded).
2344
+ */
2345
+ function preapproveTools(harness, scope, serverName = DEFAULT_SERVER_NAME, opts = {}) {
2346
+ const env = opts.env ?? defaultEnv();
2347
+ const dryRun = opts.dryRun ?? false;
2348
+ const desc = harness.autoApprove;
2349
+ const base = {
2350
+ ok: false,
2351
+ file: "",
2352
+ scope,
2353
+ fileExists: false,
2354
+ added: [],
2355
+ alreadyPresent: [],
2356
+ conflicting: [],
2357
+ addedEntries: [],
2358
+ wrote: false,
2359
+ dryRun,
2360
+ keyPath: desc ? desc.keyPath.join(".") : "",
2361
+ ...desc?.toolValue ? { toolValue: desc.toolValue } : {},
2362
+ ...desc?.caveat ? { caveat: desc.caveat } : {}
2363
+ };
2364
+ if (!desc) return {
2365
+ ...base,
2366
+ reason: "no-descriptor"
2367
+ };
2368
+ const resolved = resolvePermissionFile(harness, desc, scope, env);
2369
+ if ("reason" in resolved) return {
2370
+ ...base,
2371
+ reason: resolved.reason
2372
+ };
2373
+ const file = resolved.path;
2374
+ base.fileExists = existsSync(file);
2375
+ if (resolved.repoRoot !== void 0) {
2376
+ const ignored = isGitIgnored(env, resolved.repoRoot, file);
2377
+ if (ignored !== void 0) base.gitIgnored = ignored;
2378
+ }
2379
+ const fail = (reason, detail) => ({
2380
+ ...base,
2381
+ file,
2382
+ reason,
2383
+ ...detail ? { detail } : {}
2384
+ });
2385
+ const adapter = permissionAdapter(desc);
2386
+ if (desc.shape === "allow-list" && !adapter.appendToArray) return fail("unsupported-format", `${desc.format} permission files cannot be appended to in place`);
2387
+ const existing = readTextOrNull(file);
2388
+ if (existing === null && existsSync(file)) return fail("unsupported-config-shape", "file exists but cannot be read");
2389
+ const baseText = existing && existing.trim() ? existing : adapter.empty;
2390
+ const whole = adapter.readAt(baseText, []);
2391
+ if (whole.kind === "unsupported") return fail("unsupported-config-shape", whole.detail);
2392
+ const root = whole.kind === "value" ? whole.value : {};
2393
+ const keyPath = [...desc.keyPath];
2394
+ const currentAtKey = getAtPath(root, keyPath);
2395
+ const wanted = (opts.tools ?? LEVR_PREAPPROVED_TOOLS).map((tool) => ({
2396
+ tool,
2397
+ entry: desc.entry(serverName, tool)
2398
+ }));
2399
+ const seen = /* @__PURE__ */ new Set();
2400
+ const unique = wanted.filter((w) => seen.has(w.entry) ? false : (seen.add(w.entry), true));
2401
+ const addedEntries = [];
2402
+ const alreadyPresent = [];
2403
+ const conflicting = [];
2404
+ let nextText = baseText;
2405
+ try {
2406
+ if (desc.shape === "allow-list") {
2407
+ if (currentAtKey !== void 0 && !Array.isArray(currentAtKey)) return fail("unsupported-config-shape", `${base.keyPath} exists but is not a list — refusing to restructure it`);
2408
+ if (currentAtKey === void 0 && desc.appendOnlyToExistingKey) return fail("allowlist-key-absent", `${harness.label} uses the allowlist in its own Settings while ${base.keyPath} is absent from ${file}, and creating it would replace that allowlist. To pre-approve, add these in ${harness.label} Settings (or to an existing ${base.keyPath} in that file): ${unique.map((w) => w.entry).join(", ")}`);
2409
+ const have = new Set((currentAtKey ?? []).filter((v) => typeof v === "string"));
2410
+ for (const w of unique) if (have.has(w.entry)) alreadyPresent.push(w.entry);
2411
+ else addedEntries.push(w);
2412
+ const values = addedEntries.map((w) => w.entry);
2413
+ if (values.length > 0) nextText = currentAtKey === void 0 ? adapter.setAt(baseText, keyPath, values) : adapter.appendToArray(baseText, keyPath, values);
2414
+ } else {
2415
+ if (currentAtKey !== void 0 && (currentAtKey === null || typeof currentAtKey !== "object" || Array.isArray(currentAtKey))) return fail("unsupported-config-shape", `${base.keyPath} exists but is not an object — refusing to restructure it`);
2416
+ const map = currentAtKey ?? {};
2417
+ const value = desc.toolValue ?? {};
2418
+ for (const w of unique) {
2419
+ const cur = map[w.entry];
2420
+ if (cur === void 0) {
2421
+ addedEntries.push(w);
2422
+ continue;
2423
+ }
2424
+ const curObj = cur !== null && typeof cur === "object" && !Array.isArray(cur) ? cur : void 0;
2425
+ if (curObj !== void 0 && Object.entries(value).every(([k, v]) => curObj[k] === v)) alreadyPresent.push(w.entry);
2426
+ else conflicting.push(w.entry);
2427
+ }
2428
+ for (const w of addedEntries) nextText = adapter.setAt(nextText, [...keyPath, w.entry], { ...value });
2429
+ }
2430
+ } catch (err) {
2431
+ if (err instanceof UnsupportedConfigShapeError) return fail("unsupported-config-shape", err.detail);
2432
+ throw err;
2433
+ }
2434
+ const added = addedEntries.map((w) => w.entry);
2435
+ const result = {
2436
+ ...base,
2437
+ ok: true,
2438
+ file,
2439
+ added,
2440
+ addedEntries,
2441
+ alreadyPresent,
2442
+ conflicting
2443
+ };
2444
+ if (dryRun || added.length === 0) return result;
2445
+ const finalText = nextText.endsWith("\n") ? nextText : `${nextText}\n`;
2446
+ try {
2447
+ mkdirSync(dirname(file), { recursive: true });
2448
+ writeConfigAtomic(file, finalText);
2449
+ } catch (err) {
2450
+ return {
2451
+ ...fail("write-failed", err instanceof Error ? err.message : String(err)),
2452
+ added: [],
2453
+ addedEntries: []
2454
+ };
2455
+ }
2456
+ return {
2457
+ ...result,
2458
+ wrote: true
2459
+ };
2460
+ }
2107
2461
 
2108
2462
  //#endregion
2109
2463
  //#region src/mcp/run.ts
@@ -2347,6 +2701,9 @@ function restoreText(r) {
2347
2701
  /** One human-readable status line per outcome. */
2348
2702
  function outcomeLine(o, dryRun, switchCommand, entryName) {
2349
2703
  const r = o.result;
2704
+ const harness = getHarness(o.id);
2705
+ const scopeNote = harness ? scopeDef(harness, r.scope)?.note : void 0;
2706
+ const caveat = scopeNote ? ` [${scopeNote}]` : "";
2350
2707
  const note = (o.entryName ? ` [as ${o.entryName}]` : "") + (o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "");
2351
2708
  const where = r.path ? ` → ${r.path}` : "";
2352
2709
  const backup = r.backupPath ? dryRun ? ` [original would be backed up to ${r.backupPath}]` : ` [original backed up to ${r.backupPath}]` : "";
@@ -2355,14 +2712,14 @@ function outcomeLine(o, dryRun, switchCommand, entryName) {
2355
2712
  if (o.entryName) return `${o.label}: failed [as ${o.entryName}] — ${failureText(o, void 0, o.entryName)}`;
2356
2713
  return `${o.label}: failed — ${failureText(o, switchCommand, entryName)}`;
2357
2714
  }
2358
- if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}`;
2715
+ if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}${caveat}`;
2359
2716
  if (r.command) {
2360
- if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${replaced}${note}`;
2361
- return `${o.label} (${r.scope}): run \`${r.command}\`${replaced}${note}`;
2717
+ if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${replaced}${note}${caveat}`;
2718
+ return `${o.label} (${r.scope}): run \`${r.command}\`${replaced}${note}${caveat}`;
2362
2719
  }
2363
- if (dryRun) return `${o.label}: would update (${r.scope})${where} (dry run — no changes)${backup}${replaced}${note}`;
2364
- if (r.wrote) return `${o.label}: installed (${r.scope})${where}${backup}${replaced}${note}`;
2365
- return `${o.label}: no change (${r.scope})${where}${note}`;
2720
+ if (dryRun) return `${o.label}: would update (${r.scope})${where} (dry run — no changes)${backup}${replaced}${note}${caveat}`;
2721
+ if (r.wrote) return `${o.label}: installed (${r.scope})${where}${backup}${replaced}${note}${caveat}`;
2722
+ return `${o.label}: no change (${r.scope})${where}${note}${caveat}`;
2366
2723
  }
2367
2724
  /** Render a report as a plain multi-line summary (used by the CLI + tests). */
2368
2725
  function formatReport(report) {
@@ -2542,11 +2899,180 @@ async function mcpAddHandler(flags) {
2542
2899
  }
2543
2900
  this.process.stdout.write(`\n${nextStepsText(report)}\n`);
2544
2901
  if (report.unknownClients.length > 0 || hasFailure(report)) this.process.exitCode = 1;
2902
+ await offerPreapproval(this, report.outcomes, {
2903
+ mode: "non-interactive",
2904
+ dryRun: options.dryRun,
2905
+ flag: flags["preapprove-tools"] ?? false,
2906
+ serverName: entry.serverName
2907
+ });
2545
2908
  return;
2546
2909
  }
2547
2910
  await interactive(this, options.dryRun, url, source, flags, entry, urlKey);
2548
2911
  }
2549
2912
  /**
2913
+ * The sentence every disclosure carries, verbatim. It must stay literally
2914
+ * true of every tool in `LEVR_PREAPPROVED_TOOLS` (code review F-027).
2915
+ * deploy_gates and transition_issue stay pre-approved (user decision
2916
+ * 2026-10-15): deploy_gates can change or withdraw a gate and
2917
+ * transition_issue can waive one, so the disclosure says that instead of
2918
+ * promising otherwise.
2919
+ */
2920
+ const PREAPPROVE_EXPLANATION = "These let your agent call these Levr tools without asking each time. None of them can delete anything. deploy_gates can change or withdraw a task's gates, and transition_issue can waive a failing required gate to finish a task; every change and waiver is recorded on the gate.";
2921
+ /** The confirmation question. The default is No. */
2922
+ const PREAPPROVE_QUESTION = "Add these permissions? (y/N)";
2923
+ /** Clients this run installed into that can take a pre-approval in that scope. */
2924
+ function preapproveCandidates(outcomes) {
2925
+ const out = [];
2926
+ for (const o of outcomes) {
2927
+ if (!o.result.ok) continue;
2928
+ const harness = getHarness(o.id);
2929
+ if (!harness?.autoApprove?.file[o.result.scope]) continue;
2930
+ out.push({
2931
+ harness,
2932
+ scope: o.result.scope
2933
+ });
2934
+ }
2935
+ return out;
2936
+ }
2937
+ /** "get_issue: reads one task" for an entry's tool. */
2938
+ function describeTool(tool) {
2939
+ const description = LEVR_PREAPPROVED_TOOL_DESCRIPTIONS[tool];
2940
+ return description ? `${tool}: ${description}` : tool;
2941
+ }
2942
+ /**
2943
+ * Who the permission file reaches (code review F-030): a `project` file sits
2944
+ * in the repository and is usually committed, so it grants the tools to
2945
+ * everyone on the repo; a `local` file is personal, and when it is about to
2946
+ * be created somewhere git does not ignore, the user is told so (we never
2947
+ * edit their .gitignore).
2948
+ */
2949
+ function sharingNote(plan) {
2950
+ if (plan.scope === "project") return "This file is in your repository and is usually committed: these permissions are shared via git with everyone who uses this repo.";
2951
+ if (plan.scope === "local") {
2952
+ const note = "This file is personal to you in this repository.";
2953
+ if (!plan.fileExists && plan.gitIgnored === false) return `${note} It does not exist yet and git does not ignore it, so it would be committed by a "git add" of that folder: add it to .gitignore if you do not want to share it.`;
2954
+ return note;
2955
+ }
2956
+ return null;
2957
+ }
2958
+ /**
2959
+ * The full disclosure for one client: the exact file, who it is shared with,
2960
+ * every entry that would be added (each with what its tool does), what is
2961
+ * already there, what the entries mean, any caveat, and how to undo it.
2962
+ * Printed BEFORE anything is written, in every mode.
2963
+ */
2964
+ function buildPreapproveDisclosure(label, plan) {
2965
+ const lines = [];
2966
+ lines.push(`${label}: pre-approve Levr tools`);
2967
+ lines.push(`File: ${plan.file}`);
2968
+ const sharing = sharingNote(plan);
2969
+ if (sharing) lines.push(sharing);
2970
+ const valueNote = plan.toolValue ? ` (each set to ${JSON.stringify(plan.toolValue)})` : "";
2971
+ lines.push(`Entries to add under "${plan.keyPath}"${valueNote}:`);
2972
+ for (const e of plan.addedEntries) lines.push(` ${e.entry} (${describeTool(e.tool)})`);
2973
+ if (plan.alreadyPresent.length > 0) lines.push(`Already present, left as is: ${plan.alreadyPresent.join(", ")}`);
2974
+ if (plan.conflicting.length > 0) lines.push(`Set differently by you, left as is: ${plan.conflicting.join(", ")}`);
2975
+ lines.push(PREAPPROVE_EXPLANATION);
2976
+ if (plan.caveat) lines.push(plan.caveat);
2977
+ lines.push(`To undo: remove these entries from "${plan.keyPath}" in ${plan.file}: ` + plan.added.join(", "));
2978
+ return lines.join("\n");
2979
+ }
2980
+ function preapproveFailureText(r) {
2981
+ if (r.reason === "allowlist-key-absent" && r.detail) return `permissions not added (${r.detail})`;
2982
+ return `permissions not added (${r.detail ? `${r.reason}: ${r.detail}` : r.reason ?? "unknown"})`;
2983
+ }
2984
+ /**
2985
+ * Whether a not-`ok` result counts against an explicit `--preapprove-tools`
2986
+ * (code review F-031). `allowlist-key-absent` is the writer declining on
2987
+ * purpose to protect the user's own allowlist — the safe outcome, so it is
2988
+ * reported but never fails the run.
2989
+ */
2990
+ function isPreapproveFailure(r) {
2991
+ return !r.ok && r.reason !== "allowlist-key-absent";
2992
+ }
2993
+ /**
2994
+ * Nothing to add (code review F-052): "already pre-approved" only when every
2995
+ * entry is already there. Entries the user set differently (a Zed deny) are
2996
+ * named as theirs and left alone — never claimed as approved.
2997
+ */
2998
+ function nothingToAddText(label, plan) {
2999
+ if (plan.conflicting.length === 0) return `${label}: Levr tools are already pre-approved in ${plan.file}.`;
3000
+ const lines = [`${label}: nothing added to ${plan.file}.`, `Set differently by you, left as is: ${plan.conflicting.join(", ")}`];
3001
+ if (plan.alreadyPresent.length > 0) lines.push(`Already pre-approved: ${plan.alreadyPresent.join(", ")}`);
3002
+ return lines.join("\n");
3003
+ }
3004
+ /**
3005
+ * After a successful install, offer to pre-approve Levr's non-destructive
3006
+ * tools in each client that has a documented per-tool allow key.
3007
+ *
3008
+ * - Interactive: print the disclosure, then ask with No as the default.
3009
+ * - Non-interactive: never write unless `--preapprove-tools` was passed; with
3010
+ * it, print the same disclosure to stderr before writing.
3011
+ * - `--dry-run`: print the disclosure and write nothing.
3012
+ *
3013
+ * Error direction: fail_fallback(continue). A failure is reported as
3014
+ * "permissions not added" and the run carries on to the next client. When
3015
+ * `--preapprove-tools` was passed, the user ASKED for the write, so a
3016
+ * failure sets exit code 1 once every client has been tried (code review
3017
+ * F-031). An interactive "No" and `--dry-run` are never failures.
3018
+ *
3019
+ * Cancelling a prompt (Ctrl+C) stops the whole offer: no further client is
3020
+ * asked, and each is reported "permissions not added" (code review F-063).
3021
+ */
3022
+ async function offerPreapproval(ctx, outcomes, opts) {
3023
+ const candidates = preapproveCandidates(outcomes);
3024
+ if (candidates.length === 0) return;
3025
+ const out = (s) => ctx.process.stdout.write(`${s}\n`);
3026
+ if (opts.mode === "non-interactive" && !opts.flag && !opts.dryRun) {
3027
+ out("\nTip: re-run with --preapprove-tools to let your agent call Levr's non-destructive tools without asking each time (add --dry-run to see exactly what it would write).");
3028
+ return;
3029
+ }
3030
+ let failed = false;
3031
+ for (const [index, { harness, scope }] of candidates.entries()) {
3032
+ const plan = preapproveTools(harness, scope, opts.serverName, { dryRun: true });
3033
+ if (!plan.ok) {
3034
+ out(`\n${harness.label}: ${preapproveFailureText(plan)}`);
3035
+ if (!opts.dryRun && isPreapproveFailure(plan)) failed = true;
3036
+ continue;
3037
+ }
3038
+ if (plan.added.length === 0) {
3039
+ out(`\n${nothingToAddText(harness.label, plan)}`);
3040
+ continue;
3041
+ }
3042
+ const disclosure = buildPreapproveDisclosure(harness.label, plan);
3043
+ if (opts.dryRun) {
3044
+ out(`\n${disclosure}\n(dry run: nothing written)`);
3045
+ continue;
3046
+ }
3047
+ if (opts.mode === "non-interactive") ctx.process.stderr.write(`\n${disclosure}\n`);
3048
+ else {
3049
+ out(`\n${disclosure}`);
3050
+ const p = await import("@clack/prompts");
3051
+ const answer = await p.confirm({
3052
+ message: PREAPPROVE_QUESTION,
3053
+ initialValue: false
3054
+ });
3055
+ if (p.isCancel(answer)) {
3056
+ out(`${harness.label}: permissions not added.`);
3057
+ for (const rest of candidates.slice(index + 1)) out(`${rest.harness.label}: permissions not added (cancelled).`);
3058
+ break;
3059
+ }
3060
+ if (answer !== true) {
3061
+ out(`${harness.label}: permissions not added.`);
3062
+ continue;
3063
+ }
3064
+ }
3065
+ const written = preapproveTools(harness, scope, opts.serverName, { dryRun: false });
3066
+ if (!written.ok) {
3067
+ out(`${harness.label}: ${preapproveFailureText(written)}`);
3068
+ if (isPreapproveFailure(written)) failed = true;
3069
+ continue;
3070
+ }
3071
+ out(`${harness.label}: added ${written.added.length} permission ${written.added.length === 1 ? "entry" : "entries"} to ${written.file}.`);
3072
+ }
3073
+ if (opts.flag && failed) ctx.process.exitCode = 1;
3074
+ }
3075
+ /**
2550
3076
  * For an unpinned entry: the `--workspace` choices. Without a session there is
2551
3077
  * no list to show, so it says how to get one. Never fails the run.
2552
3078
  */
@@ -2722,6 +3248,12 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
2722
3248
  left
2723
3249
  };
2724
3250
  p.note(formatReport(report), "Results");
3251
+ await offerPreapproval(ctx, outcomes, {
3252
+ mode: "interactive",
3253
+ dryRun,
3254
+ flag: flags["preapprove-tools"] ?? false,
3255
+ serverName: entry.serverName
3256
+ });
2725
3257
  p.outro(nextStepsText(report));
2726
3258
  if (hasFailure(report)) ctx.process.exitCode = 1;
2727
3259
  }
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, VS Code, Gemini CLI, Codex CLI,
110
- Grok Build, Antigravity) and write the Levr MCP server into each one's config. The entry is credential-free — the client opens a browser to
109
+ Claude Code, Codex CLI, Grok Build, Antigravity, Gemini CLI, Cursor, Windsurf,
110
+ Zed, VS Code) 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, Windsurf, Codex CLI
119
- and Antigravity are user-only. A client you name with --client fails if it cannot honor the
118
+ Not every client supports every scope — Claude Desktop and Windsurf are user-only.
119
+ 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
 
@@ -136,12 +136,21 @@ Interactive by default; non-interactive when --all/--client/--yes is passed
136
136
  or when not running in a terminal (CI). Config edits preserve existing
137
137
  servers and comments, and re-running is a no-op.
138
138
 
139
+ Tool pre-approval (opt-in): for a client whose permission file has a
140
+ documented per-tool allow key (Claude Code, Cursor, Zed), the installer can
141
+ add entries that let your agent call Levr's non-destructive tools without
142
+ asking each time. It prints the exact file and every entry first, and asks
143
+ (default No). Non-interactively it writes them only with --preapprove-tools.
144
+ With --dry-run it prints what it would add and writes nothing. If a write
145
+ asked for with --preapprove-tools fails, the command exits 1.
146
+
139
147
  Examples:
140
148
  npx @levr-one/cli@latest mcp add # detect clients and pick interactively
141
149
  levr mcp add --all # set up every detected client
142
150
  levr mcp add --client cursor --yes
143
151
  levr mcp add --scope project # commit the config to this repo
144
152
  levr mcp add --dry-run # preview without writing
153
+ levr mcp add --client claude-code --yes --preapprove-tools
145
154
  levr mcp add --workspace acme # pin the entry to one workspace
146
155
  levr mcp add --workspace beta --replace # switch it to another
147
156
  levr mcp add --workspace beta --name levr-beta # add a second, side by side
@@ -204,12 +213,17 @@ Examples:
204
213
  kind: "boolean",
205
214
  optional: true,
206
215
  brief: "Switch an existing entry of this name to the new URL"
216
+ },
217
+ "preapprove-tools": {
218
+ kind: "boolean",
219
+ optional: true,
220
+ brief: "Non-interactive: also pre-approve Levr's non-destructive tools in the client's permission file"
207
221
  }
208
222
  },
209
223
  aliases: { y: "yes" }
210
224
  },
211
225
  loader: async () => {
212
- const { mcpAddHandler } = await import("./addHandler-B_3UmMbj.js");
226
+ const { mcpAddHandler } = await import("./addHandler-DR6q5U_R.js");
213
227
  return mcpAddHandler;
214
228
  }
215
229
  });
@@ -887,7 +901,7 @@ Examples:
887
901
 
888
902
  //#endregion
889
903
  //#region package.json
890
- var version = "0.11.9";
904
+ var version = "0.11.10";
891
905
 
892
906
  //#endregion
893
907
  //#region src/app.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levr-one/cli",
3
- "version": "0.11.9",
3
+ "version": "0.11.10",
4
4
  "description": "The command-line interface for Levr",
5
5
  "type": "module",
6
6
  "license": "MIT",