leos-agent 6.3.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -7
  3. package/adapters/cursor/agents/executor.md +1 -1
  4. package/adapters/cursor/agents/implementer.md +2 -2
  5. package/adapters/cursor/agents/review-lens.md +22 -0
  6. package/adapters/cursor/agents/reviewer.md +2 -2
  7. package/adapters/opencode/agents.json +43 -4
  8. package/adapters/opencode/plugin.js +325 -37
  9. package/config/MCP_PINS.md +17 -0
  10. package/config/models.json +276 -8
  11. package/hooks/bash-guard.py +51 -9
  12. package/package.json +3 -6
  13. package/roles/executor.md +1 -1
  14. package/roles/implementer.md +2 -2
  15. package/roles/review-lens.md +20 -0
  16. package/roles/reviewer.md +2 -2
  17. package/scripts/doctor.py +267 -31
  18. package/scripts/ghreview.py +7 -3
  19. package/scripts/jsonc_bridge.cjs +23 -0
  20. package/scripts/memory.py +74 -35
  21. package/scripts/render_adapters.py +57 -22
  22. package/scripts/resolve_attach_target.py +45 -13
  23. package/scripts/setup.py +1594 -2
  24. package/skills/brainstorming/SKILL.md +3 -1
  25. package/skills/debugging/SKILL.md +4 -2
  26. package/skills/delegation/SKILL.md +10 -8
  27. package/skills/doctor/SKILL.md +33 -14
  28. package/skills/executing-plans/SKILL.md +2 -1
  29. package/skills/finishing-a-branch/SKILL.md +4 -2
  30. package/skills/freshness/SKILL.md +23 -10
  31. package/skills/memory/SKILL.md +12 -2
  32. package/skills/resolve-ticket/SKILL.md +15 -9
  33. package/skills/review-pr/SKILL.md +26 -16
  34. package/skills/setup/SKILL.md +123 -9
  35. package/skills/setup/agents/openai.yaml +5 -0
  36. package/skills/test-first/SKILL.md +3 -1
  37. package/skills/using-leo/SKILL.md +11 -6
  38. package/skills/using-leo/references/claude-mapping.md +2 -1
  39. package/skills/using-leo/references/codex-mapping.md +4 -5
  40. package/skills/using-leo/references/cursor-mapping.md +2 -1
  41. package/skills/using-leo/references/hermes-mapping.md +2 -1
  42. package/skills/using-leo/references/opencode-mapping.md +6 -3
  43. package/skills/verification/SKILL.md +2 -1
  44. package/skills/visual-verification/SKILL.md +2 -1
  45. package/skills/watch-review/SKILL.md +17 -14
  46. package/skills/watch-review/agents/openai.yaml +5 -0
  47. package/skills/worktrees/SKILL.md +3 -1
  48. package/skills/writing-plans/SKILL.md +2 -1
  49. package/skills/writing-skills/SKILL.md +9 -2
  50. package/vendor/jsonc-parser-3.3.1/LICENSE.md +21 -0
  51. package/vendor/jsonc-parser-3.3.1/README.md +26 -0
  52. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +201 -0
  53. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +275 -0
  54. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +682 -0
  55. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +456 -0
  56. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +42 -0
  57. package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +351 -0
  58. package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +194 -0
  59. package/vendor/jsonc-parser-3.3.1/package.json +37 -0
  60. package/workflows/cost-tiered-fix.js +32 -4
package/scripts/setup.py CHANGED
@@ -20,16 +20,63 @@ radius worth a deliberate yes.
20
20
 
21
21
  Features: hermes-memory
22
22
 
23
- Exit code is 0 on success, 1 on an unknown feature or a failed write.
23
+ `setup.py apply` is a separate, ACTION layer: it bootstraps the MCP servers
24
+ `config/models.json`'s "mcp" section declares as core for whichever harness is
25
+ actually running this script (detected the same way scripts/doctor.py does —
26
+ see _detect_harness there, reused rather than re-implemented), writing only
27
+ that harness's own config, read-modify-write, idempotently. `--dry-run` prints
28
+ the exact commands or diffs and touches nothing.
29
+
30
+ setup.py apply install this harness's core MCP servers
31
+ setup.py apply --dry-run print what would happen; execute nothing
32
+ setup.py apply --harness X state the harness instead of detecting it
33
+
34
+ An unsupported (or undetectable) harness refuses outright: nothing is
35
+ touched, exit code is non-zero. Vendor connectors (Slack, Sentry, Linear...)
36
+ live in the same "mcp" config under "connectors" but apply never installs
37
+ them — see the next layer.
38
+
39
+ `setup.py connectors` is that separate, READ-ONLY layer over the same
40
+ "connectors" list. It reports which are already registered against the
41
+ running harness — matching by endpoint URL first, then by name, because a
42
+ claude.ai connector such as Gmail or Vercel is registered against the
43
+ account and never written to `~/.claude.json` at all; only `claude mcp list`
44
+ can see it — and which are not. `setup.py connect <key>...` installs one or
45
+ more of the not-yet-registered ones. Every connector is OAuth, so a
46
+ successful install is reported `needs-auth`: setup never handles a
47
+ credential, and the harness runs the browser consent flow on first use.
48
+ Snowflake's endpoint embeds org/account/database/schema and cannot be
49
+ guessed — `connect snowflake` without `--url <URL>` refuses outright,
50
+ writing nothing.
51
+
52
+ setup.py connectors list installed vs. available connectors
53
+ setup.py connectors --json the same facts as JSON, for a question tool
54
+ setup.py connect <key> [<key>...] install one or more connectors
55
+ setup.py connect <key> --url URL supply an account-specific URL (snowflake)
56
+
57
+ Nothing here is offered automatically. `connectors`/`connectors --json` never
58
+ write anything, on any harness, including an unsupported one. The multi-select
59
+ prompt itself — and the "install nothing unless asked" default when no
60
+ question tool exists — is the calling skill's job, not this script's; see
61
+ skills/setup/SKILL.md.
62
+
63
+ Exit code is 0 on success, 1 on an unknown feature, a failed write, or an
64
+ apply/connect that refused (unsupported harness, or a connector with no URL
65
+ on file and none supplied).
24
66
  """
25
67
  import json
26
68
  import os
69
+ import re
70
+ import shlex
71
+ import shutil
72
+ import subprocess
27
73
  import sys
28
74
 
29
75
  _HERE = os.path.dirname(os.path.abspath(__file__))
30
76
  sys.path.insert(0, _HERE)
31
77
 
32
- import memory # noqa: E402 (path fix must precede the import)
78
+ import doctor # noqa: E402 (path fix must precede the import)
79
+ import memory # noqa: E402
33
80
  import state # noqa: E402
34
81
 
35
82
  FEATURES = {
@@ -125,8 +172,1553 @@ def _render(data):
125
172
  return "\n".join(lines) + "\n"
126
173
 
127
174
 
175
+ # --------------------------------------------------------------------------
176
+ # apply: bootstrap the running harness's own MCP config
177
+ # --------------------------------------------------------------------------
178
+ #
179
+ # Everything below reads and writes exactly one harness's own config — the
180
+ # one scripts/doctor.py's _detect_harness (reused, not re-implemented; see
181
+ # tests/test_doctor.py:93-152) says is actually running this script. "Already
182
+ # installed" is always answered by reading that config fresh, never by a flag
183
+ # in state.json — a private flag drifts the moment a user removes a server by
184
+ # hand, and the whole point of this layer is that re-running it is safe.
185
+
186
+ STATUS_LABELS = {
187
+ "already-present": "already-present",
188
+ "installed": "installed-now",
189
+ "would-install": "would-install (dry run)",
190
+ "needs-auth": "needs-auth",
191
+ "manual": "manual-steps",
192
+ "failed": "FAILED",
193
+ "error": "error",
194
+ }
195
+ _STATUS_ORDER = ("already-present", "installed", "would-install", "needs-auth",
196
+ "manual", "failed", "error")
197
+
198
+
199
+ def _mcp_config():
200
+ models = doctor._read_json(os.path.join(doctor.PAYLOAD, "config", "models.json")) or {}
201
+ return models.get("mcp") or {}
202
+
203
+
204
+ def _canonical_roles():
205
+ agents = doctor._read_json(os.path.join(doctor.PAYLOAD, "adapters", "opencode", "agents.json")) or {}
206
+ # OpenCode's built-in `build` agent is a canonical gating target even
207
+ # though Leo does not ship an override for it in agents.json.
208
+ return {f"leo-{name}" for name in agents} | {"build"}
209
+
210
+
211
+ def _validate_mcp_config(mcp):
212
+ """Return human-readable catalogue errors; never guess past bad policy.
213
+
214
+ The catalogue drives commands that can modify a user's configuration. A
215
+ partial or typo-tolerant read is therefore unsafe: validate it before
216
+ rendering an actionable plan as well as before applying one.
217
+ """
218
+ errors = []
219
+ servers = mcp.get("servers") if isinstance(mcp, dict) else None
220
+ if not isinstance(servers, dict) or not servers:
221
+ return ["servers must be a nonempty object"]
222
+ allowed_auth = {"none", "oauth", "manual-oauth"}
223
+ for key, spec in servers.items():
224
+ if not isinstance(key, str) or not key.strip() or not isinstance(spec, dict):
225
+ errors.append("server keys and values must be nonempty strings and objects")
226
+ continue
227
+ if spec.get("transport") not in ("stdio", "http"):
228
+ errors.append(f"server {key}: transport must be stdio or http")
229
+ if spec.get("auth") not in allowed_auth:
230
+ errors.append(f"server {key}: invalid auth")
231
+ if spec.get("registration") not in ("automatic", "manual"):
232
+ errors.append(f"server {key}: registration must be automatic or manual")
233
+ prerequisites = spec.get("prerequisites")
234
+ if (not isinstance(prerequisites, list) or not prerequisites or
235
+ not all(isinstance(item, str) and item.strip() for item in prerequisites)):
236
+ errors.append(f"server {key}: prerequisites must be a nonempty string array")
237
+ command = spec.get("command")
238
+ if spec.get("transport") == "stdio" and (not isinstance(command, list) or
239
+ not command or
240
+ not all(isinstance(part, str) and part for part in command)):
241
+ errors.append(f"server {key}: stdio command must be a nonempty string array")
242
+ if isinstance(command, list) and any("@latest" in part for part in command):
243
+ errors.append(f"server {key}: commands must use reviewed exact versions, never @latest")
244
+ exact = spec.get("exactVersion")
245
+ if not isinstance(exact, str) or not exact or exact not in (command or []):
246
+ errors.append(f"server {key}: exactVersion must match one pinned command argument")
247
+
248
+ core = mcp.get("core")
249
+ if not isinstance(core, dict):
250
+ errors.append("core must be an object")
251
+ else:
252
+ for harness, refs in core.items():
253
+ if harness == "_comment":
254
+ continue
255
+ if not isinstance(refs, list) or any(ref not in servers for ref in refs):
256
+ errors.append(f"core {harness}: every entry must reference a server")
257
+
258
+ gating = mcp.get("gating") or {}
259
+ opencode = gating.get("opencode") if isinstance(gating, dict) else None
260
+ if opencode is not None:
261
+ for ref in opencode.get("off", []):
262
+ if ref not in servers:
263
+ errors.append(f"gating opencode off: unknown server {ref}")
264
+ valid_agents = _canonical_roles()
265
+ agents = opencode.get("agents", {})
266
+ if not isinstance(agents, dict):
267
+ errors.append("gating opencode agents must be an object")
268
+ else:
269
+ for agent, refs in agents.items():
270
+ if agent not in valid_agents:
271
+ errors.append(f"gating opencode: unknown agent {agent}")
272
+ if not isinstance(refs, list) or any(ref not in servers for ref in refs):
273
+ errors.append(f"gating opencode {agent}: every entry must reference a server")
274
+
275
+ connectors = mcp.get("connectors") or []
276
+ if not isinstance(connectors, list):
277
+ errors.append("connectors must be an array")
278
+ else:
279
+ seen, endpoints = set(), set()
280
+ for connector in connectors:
281
+ key = connector.get("key") if isinstance(connector, dict) else None
282
+ if not isinstance(key, str) or not key.strip() or key in seen:
283
+ errors.append("connector keys must be unique and nonempty")
284
+ continue
285
+ seen.add(key)
286
+ if connector.get("transport") != "http":
287
+ errors.append(f"connector {key}: transport must be http")
288
+ if connector.get("auth") not in allowed_auth:
289
+ errors.append(f"connector {key}: invalid auth")
290
+ if connector.get("registration") not in ("automatic", "manual"):
291
+ errors.append(f"connector {key}: registration must be automatic or manual")
292
+ prerequisites = connector.get("prerequisites")
293
+ if (not isinstance(prerequisites, list) or not prerequisites or
294
+ not all(isinstance(item, str) and item.strip() for item in prerequisites)):
295
+ errors.append(f"connector {key}: prerequisites must be a nonempty string array")
296
+ endpoint = connector.get("url")
297
+ if not isinstance(endpoint, str) or not endpoint.strip():
298
+ endpoint = connector.get("urlTemplate")
299
+ if not (connector.get("registration") == "manual" and isinstance(endpoint, str) and endpoint.strip()):
300
+ errors.append(f"connector {key}: endpoint must be nonempty")
301
+ if isinstance(endpoint, str) and endpoint.strip() and endpoint in endpoints:
302
+ errors.append(f"connector {key}: endpoint must be unique")
303
+ elif isinstance(endpoint, str) and endpoint.strip():
304
+ endpoints.add(endpoint)
305
+ return errors
306
+
307
+
308
+ def _catalogue_or_error():
309
+ mcp = _mcp_config()
310
+ errors = _validate_mcp_config(mcp)
311
+ if errors:
312
+ print("leo setup: invalid MCP catalogue; refusing to render or write:")
313
+ for error in errors:
314
+ print(" - " + error)
315
+ return None
316
+ return mcp
317
+
318
+
319
+ def _core_servers(mcp, harness):
320
+ names = (mcp.get("core") or {}).get(harness) or []
321
+ catalogue = mcp.get("servers") or {}
322
+ return [(name, catalogue[name]) for name in names if name in catalogue]
323
+
324
+
325
+ def _missing_core_runners(harness, servers, dry_run):
326
+ """Fail a real automatic apply atomically when a stdio runner is absent.
327
+
328
+ Cursor and OpenCode write their configuration directly, so discovering a
329
+ missing ``npx`` or ``uvx`` after the write would report success for wiring
330
+ that cannot run. Claude and Codex delegate the write to their CLIs, but
331
+ the same core command would still be unusable afterward. Preflight every
332
+ declared runner once, before dispatching to any installer.
333
+
334
+ Dry runs remain pure renderings, and Hermes remains a successful manual
335
+ flow. An absent harness directory also stays the install detector's
336
+ report-only/manual case rather than becoming a misleading PATH failure.
337
+ """
338
+ gate = _harness_dir(harness)
339
+ if dry_run or harness == "hermes" or not gate or not os.path.isdir(gate):
340
+ return []
341
+
342
+ missing = []
343
+ for name, spec in servers:
344
+ command = spec.get("command") or []
345
+ runner = command[0] if command else None
346
+ if runner and shutil.which(runner) is None:
347
+ prerequisites = "; ".join(spec.get("prerequisites") or [])
348
+ detail = f"required executable {runner!r} is not available on PATH"
349
+ if prerequisites:
350
+ detail += f"; prerequisites: {prerequisites}"
351
+ missing.append(_entry(name, spec, "failed", detail=detail,
352
+ command=list(command)))
353
+ return missing
354
+
355
+
356
+ def _harness_dir(harness):
357
+ """Where each harness's own config lives.
358
+
359
+ Same env-var precedence memory.py's projection_targets() already
360
+ established (CLAUDE_CONFIG_DIR, CODEX_HOME, XDG_CONFIG_HOME) — repeated
361
+ here as plain one-line lookups rather than imported, because each is a
362
+ single env-var read with no branch order to drift on. That is unlike
363
+ harness DETECTION, whose multi-branch ordering (cursor before codex,
364
+ absence-of-CLAUDE_PLUGIN_ROOT is not a codex signal, ...) is exactly what
365
+ scripts/doctor.py delegates to hooks/session-start.py rather than
366
+ re-deriving, and what apply reuses via doctor._detect_harness() below.
367
+
368
+ Its absence means the harness is not installed: apply must never create
369
+ it (memory.py:403-430's rule, mirrored here for the same reason).
370
+ """
371
+ home = os.path.expanduser("~")
372
+ if harness == "claude":
373
+ return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(home, ".claude")
374
+ if harness == "codex":
375
+ return os.environ.get("CODEX_HOME") or os.path.join(home, ".codex")
376
+ if harness == "cursor":
377
+ return os.path.join(home, ".cursor")
378
+ if harness == "opencode":
379
+ xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.join(home, ".config")
380
+ return os.path.join(xdg, "opencode")
381
+ if harness == "hermes":
382
+ return memory.hermes_home()
383
+ return None
384
+
385
+
386
+ def _read_json_file(path):
387
+ try:
388
+ with open(path, encoding="utf-8") as fh:
389
+ return json.load(fh)
390
+ except (OSError, json.JSONDecodeError):
391
+ return None
392
+
393
+
394
+ def _entry(name, spec, status, detail="", command=None, diff=None):
395
+ return {
396
+ "kind": "server", "server": name, "label": spec.get("label", name),
397
+ "status": status, "detail": detail, "command": command, "diff": diff,
398
+ }
399
+
400
+
401
+ def _gate_entry(label, status):
402
+ return {
403
+ "kind": "gate", "server": None, "label": label, "status": status,
404
+ "detail": "", "command": None, "diff": None,
405
+ }
406
+
407
+
408
+ def _no_gate_note(harness, gate):
409
+ return {
410
+ "kind": "note", "server": None, "label": f"{harness} config directory",
411
+ "status": "manual",
412
+ "detail": (f"{gate} does not exist. leo never creates a harness config "
413
+ "directory — its absence means the harness is not installed, "
414
+ "so nothing was touched."),
415
+ "command": None, "diff": None,
416
+ }
417
+
418
+
419
+ def _broken_note(harness, path, detail=""):
420
+ return {
421
+ "kind": "note", "server": None, "label": f"{harness} config file",
422
+ "status": "error",
423
+ "detail": detail or f"{path} could not be read in the expected shape; refusing to touch it.",
424
+ "command": None, "diff": None,
425
+ }
426
+
427
+
428
+ def _shell_install(name, spec, cmd, timeout=60):
429
+ """Run an install command for real. Never called from a dry run."""
430
+ try:
431
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
432
+ except (OSError, subprocess.SubprocessError) as exc:
433
+ return _entry(name, spec, "failed", command=cmd,
434
+ detail=f"could not run {cmd[0]!r}: {exc}")
435
+ if result.returncode != 0:
436
+ # `failed`, not `manual`: the command ran and refused. Hermes and
437
+ # Claude-in-Chrome reach `manual` having correctly done nothing, and
438
+ # exit 0 is right for them; a install that actually failed has to be
439
+ # visible to a caller chaining on `&&`.
440
+ detail = (result.stderr or result.stdout or "").strip()[:400]
441
+ return _entry(name, spec, "failed", command=cmd, detail=detail)
442
+ if spec.get("auth") not in (None, "none"):
443
+ return _entry(name, spec, "needs-auth", command=cmd, detail=spec.get("authNote", ""))
444
+ return _entry(name, spec, "installed", command=cmd)
445
+
446
+
447
+ # ---- claude --------------------------------------------------------------
448
+
449
+ def _claude_json_path():
450
+ # ~/.claude.json is a sibling of the ~/.claude settings directory, not a
451
+ # file inside it — confirmed against this machine's own ~/.claude.json,
452
+ # which already carries a top-level "mcpServers" key. CLAUDE_CONFIG_DIR
453
+ # is still honored for both, since it is documented to relocate the
454
+ # whole config surface, not just the settings directory.
455
+ base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~")
456
+ return os.path.join(base, ".claude.json")
457
+
458
+
459
+ def _apply_claude(servers, dry_run):
460
+ gate = _harness_dir("claude")
461
+ if not os.path.isdir(gate):
462
+ return [_no_gate_note("claude", gate)]
463
+ path = _claude_json_path()
464
+ data = _read_json_file(path)
465
+ if data is None and os.path.exists(path):
466
+ return [_broken_note("claude", path)]
467
+ data = data or {}
468
+ have = set((data.get("mcpServers") or {}).keys()) if isinstance(data, dict) else set()
469
+ entries = []
470
+ for name, spec in servers:
471
+ cmd = ["claude", "mcp", "add", "--scope", "user", name, "--", *spec["command"]]
472
+ if name in have:
473
+ entries.append(_entry(name, spec, "already-present", command=cmd))
474
+ elif dry_run:
475
+ entries.append(_entry(name, spec, "would-install", command=cmd))
476
+ else:
477
+ entries.append(_shell_install(name, spec, cmd))
478
+ return entries
479
+
480
+
481
+ # ---- codex -----------------------------------------------------------------
482
+
483
+ def _codex_core_names():
484
+ try:
485
+ result = subprocess.run(["codex", "mcp", "list", "--json"],
486
+ capture_output=True, text=True, timeout=30)
487
+ except (OSError, subprocess.SubprocessError):
488
+ return None
489
+ if result.returncode:
490
+ return None
491
+ try:
492
+ rows = json.loads(result.stdout)
493
+ except json.JSONDecodeError:
494
+ return None
495
+ if not isinstance(rows, list):
496
+ return None
497
+ return {row.get("name") for row in rows if isinstance(row, dict) and isinstance(row.get("name"), str)}
498
+
499
+
500
+ def _apply_codex(servers, dry_run):
501
+ gate = _harness_dir("codex")
502
+ if not os.path.isdir(gate):
503
+ return [_no_gate_note("codex", gate)]
504
+ present_names = _codex_core_names()
505
+ if present_names is None:
506
+ return [_broken_note("codex", gate, "could not determine Codex MCP state; refusing to write blindly")]
507
+ entries = []
508
+ for name, spec in servers:
509
+ cmd = ["codex", "mcp", "add", name, "--", *spec["command"]]
510
+ if name in present_names:
511
+ entries.append(_entry(name, spec, "already-present", command=cmd))
512
+ elif dry_run:
513
+ entries.append(_entry(name, spec, "would-install", command=cmd))
514
+ else:
515
+ entries.append(_shell_install(name, spec, cmd))
516
+ return entries
517
+
518
+
519
+ def _codex_features():
520
+ try:
521
+ result = subprocess.run(["codex", "features", "list"],
522
+ capture_output=True, text=True, timeout=30)
523
+ except (OSError, subprocess.SubprocessError):
524
+ return None
525
+ if result.returncode != 0:
526
+ return None
527
+ features = {}
528
+ for line in result.stdout.splitlines():
529
+ parts = line.split()
530
+ if len(parts) < 3:
531
+ continue
532
+ name, enabled = parts[0], parts[-1]
533
+ stage = " ".join(parts[1:-1])
534
+ features[name] = {"stage": stage, "enabled": enabled == "true"}
535
+ return features
536
+
537
+
538
+ def _codex_web_search_mode():
539
+ """The effective, persistent value — never the per-invocation --search flag.
540
+
541
+ `tools.web_search` and `features.web_search_request` are both deprecated
542
+ (confirmed against `codex features list` on this machine: both show
543
+ `deprecated false`). The live key is the top-level `web_search` scalar in
544
+ config.toml, default "cached" when absent. Read-only — writing it back is
545
+ a different matter and apply never does that (SAFETY RULES / harness
546
+ toggles: report, never flip).
547
+
548
+ A narrow read-only parser keeps this available on the supported Python
549
+ 3.9 floor without taking a TOML dependency. It recognizes only this one
550
+ top-level scalar and treats anything ambiguous as unknown.
551
+ """
552
+ path = os.path.join(_harness_dir("codex"), "config.toml")
553
+ try:
554
+ with open(path, encoding="utf-8") as fh:
555
+ text = fh.read()
556
+ except FileNotFoundError:
557
+ return "cached"
558
+ except OSError:
559
+ return None
560
+ # setup needs precisely one top-level scalar, not a TOML implementation.
561
+ # Reject duplicate/malformed assignments rather than trying to be clever:
562
+ # unreadable means unknown and must never license a write.
563
+ matches = re.findall(r'^\s*web_search\s*=\s*"([^"\\]*(?:\\.[^"\\]*)*)"\s*(?:#.*)?$',
564
+ text, flags=re.MULTILINE)
565
+ mentions = re.findall(r'^\s*web_search\s*=', text, flags=re.MULTILINE)
566
+ if not mentions:
567
+ return "cached"
568
+ if len(matches) != 1 or len(mentions) != 1:
569
+ return None
570
+ return matches[0]
571
+
572
+
573
+ def _codex_toggles():
574
+ entries = []
575
+ features = _codex_features()
576
+ computer_use = (features or {}).get("computer_use")
577
+ if computer_use is not None:
578
+ entries.append({
579
+ "name": "computer_use",
580
+ "status": "already-on" if computer_use["enabled"] else "off",
581
+ "detail": f"stage={computer_use['stage']}",
582
+ })
583
+ else:
584
+ entries.append({"name": "computer_use", "status": "unknown",
585
+ "detail": "could not run `codex features list`"})
586
+
587
+ mode = _codex_web_search_mode()
588
+ if mode is None:
589
+ entries.append({
590
+ "name": "web_search",
591
+ "status": "unknown",
592
+ "detail": ("config.toml is unreadable or its top-level web_search value is "
593
+ "malformed or ambiguous; no change will be inferred from it."),
594
+ })
595
+ elif mode == "live":
596
+ entries.append({"name": "web_search", "status": "already-live", "detail": f"mode={mode!r}"})
597
+ else:
598
+ config_path = os.path.join(_harness_dir("codex"), "config.toml")
599
+ entries.append({
600
+ "name": "web_search",
601
+ "status": "offer",
602
+ "detail": (f"currently {mode!r} (default). \"live\" grants the model unrestricted, "
603
+ "unapproved live web retrieval — a security-relevant change apply never "
604
+ f"makes for you. To take it, add `web_search = \"live\"` to {config_path} "
605
+ "yourself."),
606
+ })
607
+ return entries
608
+
609
+
610
+ # ---- claude toggle (Claude in Chrome) --------------------------------------
611
+
612
+ def _claude_toggles():
613
+ # No config key exists anywhere for this — it is a Claude Desktop UI
614
+ # toggle with no CLI or file surface to probe, so this never claims a
615
+ # state it cannot see. NEVER report it as enabled.
616
+ return [{
617
+ "name": "claude-in-chrome",
618
+ "status": "manual",
619
+ "detail": ("no config key — enable it yourself: Settings -> Connectors -> "
620
+ "Claude in Chrome. Check with the /chrome slash command."),
621
+ }]
622
+
623
+
624
+ _TOGGLES = {
625
+ "codex": _codex_toggles,
626
+ "claude": _claude_toggles,
627
+ }
628
+
629
+
630
+ # ---- cursor ------------------------------------------------------------
631
+
632
+ def _apply_cursor(servers, dry_run):
633
+ gate = _harness_dir("cursor")
634
+ if not os.path.isdir(gate):
635
+ return [_no_gate_note("cursor", gate)]
636
+ path = os.path.join(gate, "mcp.json")
637
+ data = _read_json_file(path)
638
+ if data is None and os.path.exists(path):
639
+ return [_broken_note("cursor", path)]
640
+ if not isinstance(data, dict):
641
+ data = {}
642
+ existing = data.get("mcpServers")
643
+ existing = dict(existing) if isinstance(existing, dict) else {}
644
+ entries = []
645
+ changed = False
646
+ for name, spec in servers:
647
+ if name in existing:
648
+ entries.append(_entry(name, spec, "already-present"))
649
+ continue
650
+ shape = {"command": spec["command"][0], "args": list(spec["command"][1:]), "env": {}}
651
+ if dry_run:
652
+ entries.append(_entry(name, spec, "would-install",
653
+ diff=json.dumps({name: shape}, indent=2)))
654
+ continue
655
+ existing[name] = shape
656
+ changed = True
657
+ if spec.get("auth") not in (None, "none"):
658
+ entries.append(_entry(name, spec, "needs-auth", detail=spec.get("authNote", "")))
659
+ else:
660
+ entries.append(_entry(name, spec, "installed"))
661
+ if changed:
662
+ data["mcpServers"] = existing
663
+ _write_config(path, json.dumps(data, indent=2) + "\n")
664
+ return entries
665
+
666
+
667
+ def _write_config(path, text):
668
+ """Back up and write a harness config, following a symlink to its target.
669
+
670
+ A dotfiles-managed config is a symlink into the repo that owns it. Writing
671
+ the link path directly would replace the link with a regular file, orphan
672
+ the real file, and leave the .leo-backup sitting next to a link that no
673
+ longer points anywhere useful — a silent, confusing loss of the user's own
674
+ setup. memory.py:_project_one already resolves realpath before splicing
675
+ for exactly this reason; do the same here.
676
+
677
+ The existing mode is carried across too. A harness config can legitimately
678
+ be 0600 — it may hold an API key in an `env` block — and _atomic_text
679
+ defaults to 0644, so writing without this would quietly widen the
680
+ permissions on the one file most likely to hold a secret.
681
+ """
682
+ target = os.path.realpath(path)
683
+ mode = 0o644
684
+ try:
685
+ mode = os.stat(target).st_mode & 0o777
686
+ except OSError:
687
+ pass # New file: _atomic_text's own default is the right answer.
688
+ memory._backup_once(target)
689
+ memory._atomic_text(target, text, mode)
690
+
691
+
692
+ # ---- opencode ------------------------------------------------------------
693
+
694
+
695
+ def _opencode_config_path(gate):
696
+ explicit = os.environ.get("OPENCODE_CONFIG")
697
+ if explicit:
698
+ return os.path.expanduser(explicit)
699
+ jsonc = os.path.join(gate, "opencode.jsonc")
700
+ plain = os.path.join(gate, "opencode.json")
701
+ if os.path.exists(plain) and os.path.exists(jsonc):
702
+ raise ValueError("both opencode.jsonc and opencode.json exist; refusing to choose")
703
+ if os.path.exists(plain):
704
+ return plain
705
+ return jsonc # default target for a brand-new file, per the brief
706
+
707
+
708
+ # JSONC parsing and add-only modification are delegated to the pinned,
709
+ # vendored jsonc-parser@3.3.1 UMD runtime. A missing Node runtime is unknown,
710
+ # never permission to rewrite a config through a fallback parser.
711
+ _JSONC_BRIDGE = os.path.join(_HERE, "jsonc_bridge.cjs")
712
+ _JSONC_RUNTIME = os.path.join(os.path.dirname(_HERE), "vendor", "jsonc-parser-3.3.1", "lib", "umd", "main.js")
713
+
714
+
715
+ def _jsonc_bridge(payload):
716
+ try:
717
+ result = subprocess.run(["node", _JSONC_BRIDGE, _JSONC_RUNTIME], input=json.dumps(payload),
718
+ capture_output=True, text=True, timeout=30)
719
+ except (OSError, subprocess.SubprocessError) as exc:
720
+ return None, str(exc)
721
+ if result.returncode:
722
+ return None, (result.stderr or result.stdout or "jsonc bridge failed").strip()
723
+ try:
724
+ return json.loads(result.stdout), None
725
+ except json.JSONDecodeError:
726
+ return None, "jsonc bridge returned invalid data"
727
+
728
+
729
+ def _jsonc_parse(text):
730
+ result, error = _jsonc_bridge({"mode": "parse", "text": text})
731
+ return (None, error) if error else (result.get("value"), None)
732
+
733
+
734
+ def _jsonc_missing_edits(before, after, path=()):
735
+ edits = []
736
+ for key, value in after.items():
737
+ if key not in before:
738
+ edits.append({"path": list(path + (key,)), "value": value})
739
+ elif isinstance(before[key], dict) and isinstance(value, dict):
740
+ edits.extend(_jsonc_missing_edits(before[key], value, path + (key,)))
741
+ return edits
742
+
743
+
744
+ def _jsonc_apply_missing(text, before, after):
745
+ result, error = _jsonc_bridge({"mode": "modify", "text": text,
746
+ "edits": _jsonc_missing_edits(before, after)})
747
+ return (None, error) if error else (result.get("text"), None)
748
+
749
+
750
+ def _opencode_shape_error(data, gating=None):
751
+ """Return the first unsafe OpenCode object shape, if any.
752
+
753
+ The lossless editor intentionally adds missing keys only. Treating a
754
+ scalar where an object is required as an empty object would therefore
755
+ produce a no-op edit while reporting success. A syntactically valid but
756
+ structurally invalid config is unknown state, so refuse it instead.
757
+ """
758
+ if "mcp" in data and not isinstance(data["mcp"], dict):
759
+ return "mcp must be an object"
760
+ for name, entry in (data.get("mcp") or {}).items():
761
+ if not isinstance(entry, dict):
762
+ return f"mcp.{name} must be an object"
763
+ if not gating:
764
+ return None
765
+ if gating.get("off") and "tools" in data and not isinstance(data["tools"], dict):
766
+ return "tools must be an object"
767
+ if gating.get("agents") and "agent" in data and not isinstance(data["agent"], dict):
768
+ return "agent must be an object"
769
+ agents = data.get("agent") or {}
770
+ for name in (gating.get("agents") or {}):
771
+ if name not in agents:
772
+ continue
773
+ entry = agents[name]
774
+ if not isinstance(entry, dict):
775
+ return f"agent.{name} must be an object"
776
+ if "tools" in entry and not isinstance(entry["tools"], dict):
777
+ return f"agent.{name}.tools must be an object"
778
+ return None
779
+
780
+
781
+ def _apply_opencode_gating(data, gating, dry_run):
782
+ """Write mcp.gating.opencode's tool gates, but only where a key is wholly
783
+ absent. A key that already exists — Leo's own earlier write, or a value
784
+ the user chose themselves — is left alone and reported already-present:
785
+ apply never overwrites a user's other config, and a previous run's own
786
+ gate is exactly that from apply's point of view."""
787
+ entries = []
788
+ changed = False
789
+
790
+ off = gating.get("off") or []
791
+ if off:
792
+ tools = data.get("tools")
793
+ tools = dict(tools) if isinstance(tools, dict) else {}
794
+ for name in off:
795
+ key = f"{name}*"
796
+ label = f'tools["{key}"] = false'
797
+ if key in tools:
798
+ entries.append(_gate_entry(label, "already-present"))
799
+ elif dry_run:
800
+ entries.append(_gate_entry(label, "would-install"))
801
+ else:
802
+ tools[key] = False
803
+ changed = True
804
+ entries.append(_gate_entry(label, "installed"))
805
+ if changed:
806
+ data["tools"] = tools
807
+
808
+ for agent_name, names in (gating.get("agents") or {}).items():
809
+ if not names:
810
+ continue
811
+ agents = data.get("agent")
812
+ agents = dict(agents) if isinstance(agents, dict) else {}
813
+ agent_entry = agents.get(agent_name)
814
+ agent_entry = dict(agent_entry) if isinstance(agent_entry, dict) else {}
815
+ agent_tools = agent_entry.get("tools")
816
+ agent_tools = dict(agent_tools) if isinstance(agent_tools, dict) else {}
817
+ agent_changed = False
818
+ for name in names:
819
+ key = f"{name}*"
820
+ label = f'agent["{agent_name}"].tools["{key}"] = true'
821
+ if key in agent_tools:
822
+ entries.append(_gate_entry(label, "already-present"))
823
+ elif dry_run:
824
+ entries.append(_gate_entry(label, "would-install"))
825
+ else:
826
+ agent_tools[key] = True
827
+ agent_changed = True
828
+ entries.append(_gate_entry(label, "installed"))
829
+ if agent_changed:
830
+ agent_entry["tools"] = agent_tools
831
+ agents[agent_name] = agent_entry
832
+ data["agent"] = agents
833
+ changed = True
834
+
835
+ return changed, entries
836
+
837
+
838
+ def _apply_opencode(servers, gating, dry_run):
839
+ gate = _harness_dir("opencode")
840
+ if not os.path.isdir(gate):
841
+ return [_no_gate_note("opencode", gate)]
842
+ try:
843
+ path = _opencode_config_path(gate)
844
+ except ValueError as exc:
845
+ return [_broken_note("opencode", gate, detail=str(exc))]
846
+ raw = ""
847
+ if os.path.exists(path):
848
+ try:
849
+ with open(path, encoding="utf-8") as fh:
850
+ raw = fh.read()
851
+ except OSError as exc:
852
+ return [_broken_note("opencode", path, detail=str(exc))]
853
+ data, error = _jsonc_parse(raw)
854
+ if error:
855
+ return [_broken_note("opencode", path, detail=f"not valid JSONC or unavailable parser: {error}")]
856
+ if not isinstance(data, dict):
857
+ return [_broken_note("opencode", path, detail="top level is not an object")]
858
+ else:
859
+ data = {}
860
+ shape_error = _opencode_shape_error(data, gating)
861
+ if shape_error:
862
+ return [_broken_note("opencode", path, detail=shape_error)]
863
+ before = json.loads(json.dumps(data))
864
+
865
+ mcp = data.get("mcp")
866
+ mcp = dict(mcp) if isinstance(mcp, dict) else {}
867
+ entries = []
868
+ changed = False
869
+ for name, spec in servers:
870
+ if isinstance(mcp.get(name), dict):
871
+ entries.append(_entry(name, spec, "already-present"))
872
+ continue
873
+ shape = {"type": "local", "command": list(spec["command"]), "enabled": True}
874
+ if dry_run:
875
+ entries.append(_entry(name, spec, "would-install",
876
+ diff=json.dumps({name: shape}, indent=2)))
877
+ continue
878
+ mcp[name] = shape
879
+ changed = True
880
+ if spec.get("auth") not in (None, "none"):
881
+ entries.append(_entry(name, spec, "needs-auth", detail=spec.get("authNote", "")))
882
+ else:
883
+ entries.append(_entry(name, spec, "installed"))
884
+ if changed:
885
+ data["mcp"] = mcp
886
+
887
+ gate_changed, gate_entries = _apply_opencode_gating(data, gating, dry_run)
888
+ entries.extend(gate_entries)
889
+ changed = changed or gate_changed
890
+
891
+ if dry_run or not changed:
892
+ return entries
893
+
894
+ if raw:
895
+ text, error = _jsonc_apply_missing(raw, before, data)
896
+ if error:
897
+ return entries + [_broken_note("opencode", path, detail=f"could not edit JSONC: {error}")]
898
+ else:
899
+ text = json.dumps(data, indent=2) + "\n"
900
+ _write_config(path, text)
901
+ return entries
902
+
903
+
904
+ # ---- hermes ------------------------------------------------------------
905
+
906
+ def _yaml_scalar(value):
907
+ """A JSON-quoted string is a valid YAML flow scalar — the same trick
908
+ memory.py:176-178 uses for frontmatter, and for the same reason: it needs
909
+ no YAML library to be correct."""
910
+ if re.fullmatch(r"[A-Za-z0-9._/@-]+", value):
911
+ return value
912
+ return json.dumps(value)
913
+
914
+
915
+ def _hermes_yaml_block(servers):
916
+ """A copy-paste block, never written to disk (see module note above apply
917
+ and the SAFETY RULES in the brief this implements: no YAML library ships
918
+ with this Python, and the repo carries no third-party deps, so
919
+ config.yaml itself is never touched). This is deliberately the simplest
920
+ possible emitter — it only ever serializes a server name plus a flat list
921
+ of plain-string command args, which is exactly what mcp.servers in
922
+ config/models.json holds, so no general-purpose YAML writer is needed.
923
+ The key name (mcp_servers) is this script's best-effort guess at Hermes'
924
+ schema; there is no Hermes install on the machine this was written on to
925
+ confirm it against, so the surrounding report says so and asks the user
926
+ to adjust it if their version differs.
927
+ """
928
+ lines = ["mcp_servers:"]
929
+ for name, spec in servers:
930
+ command = spec["command"]
931
+ lines.append(f" {name}:")
932
+ lines.append(f" command: {_yaml_scalar(command[0])}")
933
+ if len(command) > 1:
934
+ args = ", ".join(_yaml_scalar(a) for a in command[1:])
935
+ lines.append(f" args: [{args}]")
936
+ return "\n".join(lines) + "\n"
937
+
938
+
939
+ def _apply_hermes(servers, dry_run):
940
+ entries = []
941
+ for index, (name, spec) in enumerate(servers):
942
+ entries.append({
943
+ "kind": "server", "server": name, "label": spec.get("label", name),
944
+ "status": "manual",
945
+ "detail": ("Hermes' config.yaml has no writer here — no YAML library ships "
946
+ "with this Python and the repo carries no third-party deps — so "
947
+ "nothing is installed. Paste the block below into "
948
+ "$HERMES_HOME/config.yaml yourself; the key name is a best-effort "
949
+ "guess (no Hermes install was available to confirm the schema "
950
+ "against) — adjust it if your version differs."),
951
+ "command": None,
952
+ "diff": _hermes_yaml_block(servers) if index == 0 else None,
953
+ })
954
+ return entries
955
+
956
+
957
+ _INSTALL = {
958
+ "claude": _apply_claude,
959
+ "codex": _apply_codex,
960
+ "cursor": _apply_cursor,
961
+ "hermes": _apply_hermes,
962
+ }
963
+
964
+
965
+ # ---- rendering + dispatch -------------------------------------------------
966
+
967
+ def _render_unsupported(harness, source):
968
+ checked = list(doctor.HARNESS_ENV) + ["LEOS_AGENT_HARNESS"]
969
+ present = [v for v in checked if os.environ.get(v)]
970
+ absent = [v for v in checked if not os.environ.get(v)]
971
+ lines = [
972
+ "leo setup apply: cannot determine a supported harness — refusing to touch anything.",
973
+ f" detected: {harness!r} (via {source})",
974
+ f" env present: {', '.join(present) or '(none)'}",
975
+ f" env absent: {', '.join(absent)}",
976
+ "",
977
+ f" known harnesses: {', '.join(sorted(doctor._known_harnesses()))}",
978
+ " Pass --harness <name> to state it explicitly.",
979
+ ]
980
+ return "\n".join(lines) + "\n"
981
+
982
+
983
+ def _render_apply(harness, source, dry_run, entries, toggles):
984
+ lines = [
985
+ f"leo setup apply — harness: {harness} (detected via {source})"
986
+ + (" [dry run — nothing executed]" if dry_run else ""),
987
+ "",
988
+ "MCP servers",
989
+ ]
990
+ servers = [e for e in entries if e["kind"] != "gate"]
991
+ gates = [e for e in entries if e["kind"] == "gate"]
992
+ if not servers:
993
+ lines.append(" (no core servers configured for this harness)")
994
+ by_status = {}
995
+ for e in servers:
996
+ by_status.setdefault(e["status"], []).append(e)
997
+ for status in _STATUS_ORDER:
998
+ for e in by_status.get(status, []):
999
+ label = STATUS_LABELS.get(status, status)
1000
+ server = e["server"] or "-"
1001
+ lines.append(f" {label:<24} {server:<16} {e['label']}")
1002
+ if e.get("command"):
1003
+ lines.append(f" $ {shlex.join(e['command'])}")
1004
+ if e.get("detail"):
1005
+ lines.extend(f" {line}" for line in e["detail"].splitlines())
1006
+ if e.get("diff"):
1007
+ lines.append(" --- would write ---" if dry_run else " --- paste this ---")
1008
+ lines.extend(f" {line}" for line in e["diff"].splitlines())
1009
+
1010
+ if gates:
1011
+ lines.append("")
1012
+ lines.append("OpenCode tool gating")
1013
+ by_status = {}
1014
+ for e in gates:
1015
+ by_status.setdefault(e["status"], []).append(e)
1016
+ for status in _STATUS_ORDER:
1017
+ for e in by_status.get(status, []):
1018
+ lines.append(f" {STATUS_LABELS.get(status, status):<24} {e['label']}")
1019
+
1020
+ if toggles:
1021
+ lines.append("")
1022
+ lines.append("Harness toggles (report only — apply never flips these for you)")
1023
+ for t in toggles:
1024
+ lines.append(f" {t['name']}: {t['status']}")
1025
+ if t.get("detail"):
1026
+ lines.extend(f" {line}" for line in t["detail"].splitlines())
1027
+
1028
+ lines.append("")
1029
+ return "\n".join(lines)
1030
+
1031
+
1032
+ def cmd_apply(argv):
1033
+ dry_run = "--dry-run" in argv
1034
+ detect_argv = [a for a in argv if a != "--dry-run"]
1035
+ harness, source = doctor._detect_harness(detect_argv)
1036
+ known = doctor._known_harnesses()
1037
+ if harness == "unknown" or harness not in known:
1038
+ print(_render_unsupported(harness, source))
1039
+ return 1
1040
+
1041
+ mcp = _catalogue_or_error()
1042
+ if mcp is None:
1043
+ return 1
1044
+ servers = _core_servers(mcp, harness)
1045
+ entries = _missing_core_runners(harness, servers, dry_run)
1046
+ if entries:
1047
+ pass # Atomic preflight failure: no installer or config writer runs.
1048
+ elif harness == "opencode":
1049
+ gating = ((mcp.get("gating") or {}).get("opencode")) or {}
1050
+ entries = _apply_opencode(servers, gating, dry_run)
1051
+ else:
1052
+ installer = _INSTALL.get(harness)
1053
+ if installer is None:
1054
+ print(f"leo setup apply: {harness!r} has no installer wired up yet.")
1055
+ return 1
1056
+ entries = installer(servers, dry_run)
1057
+ toggles = _TOGGLES.get(harness, lambda: [])()
1058
+ print(_render_apply(harness, source, dry_run, entries, toggles))
1059
+ # A refused write (malformed config) or an install command that ran and
1060
+ # returned non-zero is a failure even though the run reported it politely —
1061
+ # the docstring promises 1 on a failed write, and a caller chaining on `&&`
1062
+ # has to be able to see it. `manual` is not a failure: Hermes and
1063
+ # Claude-in-Chrome reach it having correctly done nothing. Missing required
1064
+ # executables are `failed` and therefore nonzero.
1065
+ if any(entry.get("status") in ("error", "failed") for entry in entries):
1066
+ return 1
1067
+ return 0
1068
+
1069
+
1070
+ # --------------------------------------------------------------------------
1071
+ # connectors / connect: vendor MCP servers, opt-in and never auto-offered
1072
+ # --------------------------------------------------------------------------
1073
+ #
1074
+ # `apply` bootstraps the CORE servers every harness gets. Vendor connectors
1075
+ # (Slack, Sentry, Linear, ...) are a second, deliberately separate list in
1076
+ # the same `mcp` config — `mcp.connectors` — that `apply` never touches.
1077
+ # `connectors`/`connectors --json` are READ-ONLY: they report which of those
1078
+ # are already registered against the running harness and which are not, so
1079
+ # an agent-side skill can drive a multi-select from the JSON and this script
1080
+ # never has to guess what the user wants. `connect <key>...` is the only
1081
+ # thing here that writes, and only for the keys named explicitly.
1082
+
1083
+ def _connector_catalogue():
1084
+ return {c["key"]: c for c in (_mcp_config().get("connectors") or []) if c.get("key")}
1085
+
1086
+
1087
+ def _index_url_servers(servers):
1088
+ """From a harness's own {name: entry} MCP server map, everything needed
1089
+ to match a connector: every registered name, and a url -> name map for
1090
+ entries that carry a "url" key (every connector's shape, on every
1091
+ harness that writes JSON directly — see _connect_cursor/_connect_opencode
1092
+ below). A server with no "url" (a local/stdio entry) still counts for the
1093
+ name-match fallback."""
1094
+ names, by_url = set(), {}
1095
+ for name, entry in servers.items():
1096
+ names.add(name)
1097
+ if isinstance(entry, dict) and entry.get("url"):
1098
+ by_url[entry["url"]] = name
1099
+ return names, by_url
1100
+
1101
+
1102
+ def _parse_claude_mcp_list_line(line):
1103
+ """`<name>: <url-or-command> - <status>`, e.g.
1104
+ 'claude.ai Granola: https://mcp.granola.ai/mcp - ✔ Connected' or
1105
+ 'context7: npx -y @upstash/context7-mcp - ✔ Connected'. Header and
1106
+ blank lines carry neither ': ' nor ' - ' and are skipped rather than
1107
+ mis-parsed.
1108
+
1109
+ The value is reduced to its first whitespace-delimited token rather than
1110
+ kept whole. This format is not a contract, and every plausible drift —
1111
+ an extra ` - 12 tools`, a ` (HTTP)` marker — otherwise survives the
1112
+ rsplit and lands in the URL map under a key nothing will ever match. That
1113
+ failure is silent and worse than useless: an installed connector reads as
1114
+ available and `connect` registers it twice.
1115
+ """
1116
+ left, sep, rest = line.strip().partition(": ")
1117
+ if not sep or not left:
1118
+ return None
1119
+ parts = rest.rsplit(" - ", 1)
1120
+ if len(parts) != 2 or not parts[0].strip():
1121
+ return None
1122
+ value = parts[0].strip().split()[0]
1123
+ return left, value
1124
+
1125
+
1126
+ def _claude_registered(gate):
1127
+ """Every name/URL this harness's own view of MCP servers already
1128
+ carries: the `mcpServers` block `~/.claude.json` holds at user scope
1129
+ (what apply's core install and this file's own `--scope user` connect
1130
+ both write), PLUS whatever `claude mcp list` reports live. The second
1131
+ read is what actually matters for the connector menu — a claude.ai
1132
+ connector such as Gmail or Vercel is registered against the account,
1133
+ never written into `~/.claude.json` at all, and `claude mcp list` is the
1134
+ only place its URL is visible (confirmed against this machine's own
1135
+ Claude Code install).
1136
+
1137
+ Returns (names, by_url, available). `available` is False when the live
1138
+ read did not happen, because "I looked and found nothing" and "I could
1139
+ not look" must not render the same: the second one offering all eleven
1140
+ connectors would have the user re-register what they already have.
1141
+ """
1142
+ names, by_url = set(), {}
1143
+ if not os.path.isdir(gate):
1144
+ return names, by_url, False
1145
+ data = _read_json_file(_claude_json_path()) or {}
1146
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
1147
+ servers = servers if isinstance(servers, dict) else {}
1148
+ inner_names, inner_by_url = _index_url_servers(servers)
1149
+ names |= inner_names
1150
+ by_url.update(inner_by_url)
1151
+ try:
1152
+ result = subprocess.run(["claude", "mcp", "list"], capture_output=True, text=True, timeout=30)
1153
+ except (OSError, subprocess.SubprocessError):
1154
+ result = None
1155
+ available = result is not None and result.returncode == 0
1156
+ if available:
1157
+ parsed_any = False
1158
+ for line in result.stdout.splitlines():
1159
+ parsed = _parse_claude_mcp_list_line(line)
1160
+ if parsed is None:
1161
+ continue
1162
+ parsed_any = True
1163
+ name, value = parsed
1164
+ names.add(name)
1165
+ if value.startswith("http://") or value.startswith("https://"):
1166
+ by_url[value] = name
1167
+ # Exited clean, said something, and none of it parsed: the output
1168
+ # format moved. Degraded, not empty — see the docstring.
1169
+ if result.stdout.strip() and not parsed_any:
1170
+ available = False
1171
+ return names, by_url, available
1172
+
1173
+
1174
+ def _codex_registered(gate):
1175
+ """`codex mcp list --json` is the one read that carries the actual
1176
+ registered URL for a streamable-HTTP server (confirmed against
1177
+ codex-cli 0.145.0: `transport.type == "streamable_http"`,
1178
+ `transport.url` holds the endpoint). Parsing config.toml directly would
1179
+ need tomllib, 3.11+, not guaranteed on this file's Python floor; `codex
1180
+ mcp get <name>` only proves presence under a name already known, not the
1181
+ URL a connector may have been registered under a different one with.
1182
+
1183
+ Returns (names, by_url, available); see _claude_registered on why a read
1184
+ that could not happen must not look like a read that found nothing.
1185
+ """
1186
+ names, by_url = set(), {}
1187
+ if not os.path.isdir(gate):
1188
+ return names, by_url, False
1189
+ try:
1190
+ result = subprocess.run(["codex", "mcp", "list", "--json"],
1191
+ capture_output=True, text=True, timeout=30)
1192
+ except (OSError, subprocess.SubprocessError):
1193
+ return names, by_url, False
1194
+ if result.returncode != 0:
1195
+ return names, by_url, False
1196
+ try:
1197
+ data = json.loads(result.stdout)
1198
+ except json.JSONDecodeError:
1199
+ return names, by_url, False
1200
+ if not isinstance(data, list):
1201
+ return names, by_url, False
1202
+ for entry in data:
1203
+ if not isinstance(entry, dict):
1204
+ continue
1205
+ name = entry.get("name")
1206
+ if not name:
1207
+ continue
1208
+ names.add(name)
1209
+ url = (entry.get("transport") or {}).get("url")
1210
+ if url:
1211
+ by_url[url] = name
1212
+ return names, by_url, True
1213
+
1214
+
1215
+ def _read_cursor_existing():
1216
+ gate = _harness_dir("cursor")
1217
+ if not os.path.isdir(gate):
1218
+ return {}
1219
+ path = os.path.join(gate, "mcp.json")
1220
+ data = _read_json_file(path)
1221
+ if data is None and os.path.exists(path):
1222
+ return None
1223
+ existing = data.get("mcpServers") if isinstance(data, dict) else None
1224
+ return existing if isinstance(existing, dict) else {}
1225
+
1226
+
1227
+ def _read_opencode_mcp():
1228
+ gate = _harness_dir("opencode")
1229
+ if not os.path.isdir(gate):
1230
+ return {}
1231
+ try:
1232
+ path = _opencode_config_path(gate)
1233
+ except ValueError:
1234
+ return None
1235
+ if not os.path.exists(path):
1236
+ return {}
1237
+ try:
1238
+ with open(path, encoding="utf-8") as fh:
1239
+ raw = fh.read()
1240
+ data, error = _jsonc_parse(raw)
1241
+ if error:
1242
+ return None
1243
+ except OSError:
1244
+ return None
1245
+ if not isinstance(data, dict) or _opencode_shape_error(data):
1246
+ return None
1247
+ return data.get("mcp") or {}
1248
+
1249
+
1250
+ def _hermes_registered():
1251
+ """Read Hermes' own registry; an unfamiliar format is unknown, not empty."""
1252
+ try:
1253
+ result = subprocess.run(["hermes", "mcp", "list"], capture_output=True, text=True, timeout=30)
1254
+ except (OSError, subprocess.SubprocessError):
1255
+ return set(), {}, False
1256
+ if result.returncode:
1257
+ return set(), {}, False
1258
+ output = result.stdout.strip()
1259
+ if not output or re.search(r"\b(no|none|0)\b.*\b(mcp|server)", output, re.I):
1260
+ return set(), {}, True
1261
+ known = set(_connector_catalogue())
1262
+ names = set()
1263
+ for line in output.splitlines():
1264
+ match = re.match(r"\s*([A-Za-z0-9_.-]+)(?:\s|:)", line)
1265
+ if match and match.group(1) in known:
1266
+ names.add(match.group(1))
1267
+ return (names, {}, True) if names else (set(), {}, False)
1268
+
1269
+
1270
+ _REGISTERED = {
1271
+ "claude": lambda: _claude_registered(_harness_dir("claude")),
1272
+ "codex": lambda: _codex_registered(_harness_dir("codex")),
1273
+ # The file-backed harnesses read their own config directly, so detection
1274
+ # is always available; hermes has no YAML reader, so it is never available
1275
+ # and the report must say so rather than imply an empty machine.
1276
+ "cursor": lambda: (_index_url_servers(_read_cursor_existing()) + (True,)
1277
+ if _read_cursor_existing() is not None else (set(), {}, False)),
1278
+ "opencode": lambda: (_index_url_servers(_read_opencode_mcp()) + (True,)
1279
+ if _read_opencode_mcp() is not None else (set(), {}, False)),
1280
+ "hermes": _hermes_registered,
1281
+ }
1282
+
1283
+
1284
+ def _normalize_endpoint(url):
1285
+ """Compare endpoints the way a server would, not the way a string does.
1286
+
1287
+ The URL map exists to catch a connector registered under a name of the
1288
+ user's choosing. An exact string compare defeats that on the differences
1289
+ every URL picks up in the wild — a trailing slash, a capitalized host —
1290
+ and a false negative here is not a cosmetic miss: `connect` goes on to
1291
+ register the same endpoint a second time under its own key.
1292
+ """
1293
+ text = (url or "").strip()
1294
+ if not text:
1295
+ return ""
1296
+ scheme, sep, rest = text.partition("://")
1297
+ if not sep:
1298
+ return text.rstrip("/").lower()
1299
+ host, slash, path = rest.partition("/")
1300
+ normalized = scheme.lower() + "://" + host.lower()
1301
+ if slash:
1302
+ normalized += "/" + path
1303
+ return normalized.rstrip("/")
1304
+
1305
+
1306
+ def _already_registered(key, url, names, by_url):
1307
+ """The single already-present test, shared by the report and every
1308
+ connect path. They must agree: a report that says `installed` while
1309
+ connect writes it anyway is how one endpoint ends up registered twice.
1310
+ """
1311
+ target = _normalize_endpoint(url)
1312
+ if target and target in {_normalize_endpoint(k) for k in by_url}:
1313
+ return True
1314
+ return key in names
1315
+
1316
+
1317
+ def _connector_installed(connector, names, by_url):
1318
+ return _already_registered(connector["key"], connector.get("url"), names, by_url)
1319
+
1320
+
1321
+ def _render_connectors_unsupported(harness, source):
1322
+ return (
1323
+ f"leo setup connectors: cannot determine a supported harness (detected "
1324
+ f"{harness!r} via {source}) — nothing installable to report.\n"
1325
+ " Pass --harness <name> to state it explicitly.\n"
1326
+ )
1327
+
1328
+
1329
+ def _render_connectors(harness, source, items, detection_available=True):
1330
+ lines = [f"leo setup connectors — harness: {harness} (detected via {source})", ""]
1331
+ installed = [c for c in items if c["installed"]]
1332
+ available = [c for c in items if not c["installed"]]
1333
+ if not detection_available:
1334
+ # Every other blind spot in this file is disclosed in its own output;
1335
+ # this one has to be too, or an empty Installed list reads as fact.
1336
+ lines.append(" NOTE: could not read this harness's registered servers, so nothing")
1337
+ lines.append(" below is known to be installed — the list may offer what you already")
1338
+ lines.append(" have. Check the harness yourself before installing.")
1339
+ lines.append("")
1340
+ lines.append("Installed")
1341
+ for c in installed or [None]:
1342
+ lines.append(" (none)" if c is None else f" {c['key']:<14} {c['label']}")
1343
+ lines.append("")
1344
+ lines.append("Available (not installed)")
1345
+ for c in available or [None]:
1346
+ if c is None:
1347
+ lines.append(" (none)")
1348
+ continue
1349
+ note = " [needs --url — the endpoint cannot be guessed]" if c["needsUrl"] else ""
1350
+ lines.append(f" {c['key']:<14} {c['label']}{note}")
1351
+ if c["authNote"]:
1352
+ lines.append(f" {c['authNote']}")
1353
+ lines.append("")
1354
+ lines.append("Install with: setup.py connect <key> [<key> ...]")
1355
+ lines.append("(snowflake also needs: setup.py connect snowflake --url <URL>)")
1356
+ return "\n".join(lines) + "\n"
1357
+
1358
+
1359
+ def cmd_connectors(argv):
1360
+ argv = list(argv)
1361
+ json_out = "--json" in argv
1362
+ harness, source = doctor._detect_harness(argv)
1363
+ known = doctor._known_harnesses()
1364
+ mcp = _catalogue_or_error()
1365
+ if mcp is None:
1366
+ return 1
1367
+ connectors = [c for c in (mcp.get("connectors") or [])]
1368
+
1369
+ if harness == "unknown" or harness not in known:
1370
+ if json_out:
1371
+ print(json.dumps({"harness": harness, "source": source, "supported": False,
1372
+ "connectors": []}, indent=2, sort_keys=True))
1373
+ else:
1374
+ print(_render_connectors_unsupported(harness, source))
1375
+ return 0
1376
+
1377
+ registered = _REGISTERED.get(harness, lambda: (set(), {}, False))
1378
+ names, by_url, detection_available = registered()
1379
+ items = []
1380
+ for c in connectors:
1381
+ url = c.get("url") or ""
1382
+ present = _connector_installed(c, names, by_url)
1383
+ # A successful local/config read can prove presence even when a
1384
+ # supplemental live CLI query failed; it can never prove absence.
1385
+ presence = "present" if present else ("absent" if detection_available else "unknown")
1386
+ items.append({
1387
+ "key": c["key"],
1388
+ "label": c.get("label", c["key"]),
1389
+ "url": url,
1390
+ "transport": c.get("transport", ""),
1391
+ "auth": c.get("auth", ""),
1392
+ "authNote": c.get("authNote", ""),
1393
+ "registration": c.get("registration", ""),
1394
+ "prerequisites": c.get("prerequisites", []),
1395
+ "installed": presence == "present", # compatibility for callers
1396
+ "presence": presence,
1397
+ "needsUrl": not bool(url),
1398
+ })
1399
+
1400
+ if json_out:
1401
+ print(json.dumps({"harness": harness, "source": source, "supported": True,
1402
+ "detectionAvailable": detection_available,
1403
+ "connectors": items}, indent=2, sort_keys=True))
1404
+ else:
1405
+ print(_render_connectors(harness, source, items, detection_available))
1406
+ return 0
1407
+
1408
+
1409
+ # ---- connect: the one action in this section ------------------------------
1410
+
1411
+ _CODEX_CONNECTOR_TIMEOUT = 12 # seconds
1412
+
1413
+
1414
+ def _shell_install_codex_connector(name, spec, cmd):
1415
+ """`codex mcp add <name> --url <url>` writes the entry to config.toml
1416
+ immediately, then — for every OAuth-capable server, which is all eleven
1417
+ connectors — blocks waiting on a local callback for the browser consent
1418
+ flow, with no flag to skip it (confirmed against codex-cli 0.145.0:
1419
+ "Added global MCP server '<name>'." prints and the entry is already on
1420
+ disk before "Starting OAuth flow..." even appears). setup never waits on
1421
+ the user's browser — that is the harness's job, on first use — so this
1422
+ timeout is short and deliberate. On expiry the process is killed and the
1423
+ harness's own config is re-read once to see whether the write landed."""
1424
+ try:
1425
+ result = subprocess.run(cmd, capture_output=True, text=True,
1426
+ timeout=_CODEX_CONNECTOR_TIMEOUT)
1427
+ except subprocess.TimeoutExpired:
1428
+ names, _, _ = _codex_registered(_harness_dir("codex"))
1429
+ if name in names:
1430
+ return _entry(name, spec, "needs-auth", command=cmd, detail=spec.get("authNote", ""))
1431
+ return _entry(name, spec, "manual", command=cmd,
1432
+ detail=(f"`{shlex.join(cmd)}` did not finish within "
1433
+ f"{_CODEX_CONNECTOR_TIMEOUT}s and no entry was written; "
1434
+ "run it yourself."))
1435
+ except (OSError, subprocess.SubprocessError) as exc:
1436
+ return _entry(name, spec, "manual", command=cmd, detail=f"could not run {cmd[0]!r}: {exc}")
1437
+ if result.returncode != 0:
1438
+ detail = (result.stderr or result.stdout or "").strip()[:400]
1439
+ return _entry(name, spec, "failed", command=cmd, detail=detail)
1440
+ return _entry(name, spec, "needs-auth", command=cmd, detail=spec.get("authNote", ""))
1441
+
1442
+
1443
+ def _connect_claude(resolved):
1444
+ gate = _harness_dir("claude")
1445
+ if not os.path.isdir(gate):
1446
+ return [_no_gate_note("claude", gate)]
1447
+ names, by_url, _ = _claude_registered(gate)
1448
+ entries = []
1449
+ for key, spec in resolved:
1450
+ url = spec["url"]
1451
+ if _already_registered(key, url, names, by_url):
1452
+ entries.append(_entry(key, spec, "already-present"))
1453
+ continue
1454
+ cmd = ["claude", "mcp", "add", "--transport", "http", "--scope", "user", key, url]
1455
+ entries.append(_shell_install(key, spec, cmd))
1456
+ return entries
1457
+
1458
+
1459
+ def _connect_codex(resolved):
1460
+ gate = _harness_dir("codex")
1461
+ if not os.path.isdir(gate):
1462
+ return [_no_gate_note("codex", gate)]
1463
+ names, by_url, _ = _codex_registered(gate)
1464
+ entries = []
1465
+ for key, spec in resolved:
1466
+ url = spec["url"]
1467
+ if _already_registered(key, url, names, by_url):
1468
+ entries.append(_entry(key, spec, "already-present"))
1469
+ continue
1470
+ cmd = ["codex", "mcp", "add", key, "--url", url]
1471
+ entries.append(_shell_install_codex_connector(key, spec, cmd))
1472
+ return entries
1473
+
1474
+
1475
+ def _connect_cursor(resolved):
1476
+ gate = _harness_dir("cursor")
1477
+ if not os.path.isdir(gate):
1478
+ return [_no_gate_note("cursor", gate)]
1479
+ path = os.path.join(gate, "mcp.json")
1480
+ data = _read_json_file(path)
1481
+ if data is None and os.path.exists(path):
1482
+ return [_broken_note("cursor", path)]
1483
+ if not isinstance(data, dict):
1484
+ data = {}
1485
+ existing = data.get("mcpServers")
1486
+ existing = dict(existing) if isinstance(existing, dict) else {}
1487
+ names, by_url = _index_url_servers(existing)
1488
+ entries = []
1489
+ changed = False
1490
+ for key, spec in resolved:
1491
+ url = spec["url"]
1492
+ if _already_registered(key, url, names, by_url):
1493
+ entries.append(_entry(key, spec, "already-present"))
1494
+ continue
1495
+ existing[key] = {"url": url}
1496
+ changed = True
1497
+ entries.append(_entry(key, spec, "needs-auth", detail=spec.get("authNote", "")))
1498
+ if changed:
1499
+ data["mcpServers"] = existing
1500
+ _write_config(path, json.dumps(data, indent=2) + "\n")
1501
+ return entries
1502
+
1503
+
1504
+ def _connect_opencode(resolved):
1505
+ gate = _harness_dir("opencode")
1506
+ if not os.path.isdir(gate):
1507
+ return [_no_gate_note("opencode", gate)]
1508
+ try:
1509
+ path = _opencode_config_path(gate)
1510
+ except ValueError as exc:
1511
+ return [_broken_note("opencode", gate, detail=str(exc))]
1512
+ raw = ""
1513
+ if os.path.exists(path):
1514
+ try:
1515
+ with open(path, encoding="utf-8") as fh:
1516
+ raw = fh.read()
1517
+ except OSError as exc:
1518
+ return [_broken_note("opencode", path, detail=str(exc))]
1519
+ data, error = _jsonc_parse(raw)
1520
+ if error:
1521
+ return [_broken_note("opencode", path, detail=f"not valid JSONC or unavailable parser: {error}")]
1522
+ if not isinstance(data, dict):
1523
+ return [_broken_note("opencode", path, detail="top level is not an object")]
1524
+ else:
1525
+ data = {}
1526
+ shape_error = _opencode_shape_error(data)
1527
+ if shape_error:
1528
+ return [_broken_note("opencode", path, detail=shape_error)]
1529
+ before = json.loads(json.dumps(data))
1530
+
1531
+ mcp = data.get("mcp")
1532
+ mcp = dict(mcp) if isinstance(mcp, dict) else {}
1533
+ names, by_url = _index_url_servers(mcp)
1534
+ entries = []
1535
+ changed = False
1536
+ for key, spec in resolved:
1537
+ url = spec["url"]
1538
+ if _already_registered(key, url, names, by_url):
1539
+ entries.append(_entry(key, spec, "already-present"))
1540
+ continue
1541
+ mcp[key] = {"type": "remote", "url": url, "enabled": True}
1542
+ changed = True
1543
+ entries.append(_entry(key, spec, "needs-auth", detail=spec.get("authNote", "")))
1544
+ if changed:
1545
+ data["mcp"] = mcp
1546
+
1547
+ if not changed:
1548
+ return entries
1549
+
1550
+ if raw:
1551
+ text, error = _jsonc_apply_missing(raw, before, data)
1552
+ if error:
1553
+ return entries + [_broken_note("opencode", path, detail=f"could not edit JSONC: {error}")]
1554
+ else:
1555
+ text = json.dumps(data, indent=2) + "\n"
1556
+ _write_config(path, text)
1557
+ return entries
1558
+
1559
+
1560
+ def _hermes_connector_yaml_block(resolved):
1561
+ lines = ["mcp_servers:"]
1562
+ for key, spec in resolved:
1563
+ lines.append(f" {key}:")
1564
+ lines.append(f" url: {_yaml_scalar(spec['url'])}")
1565
+ return "\n".join(lines) + "\n"
1566
+
1567
+
1568
+ def _connect_hermes(resolved):
1569
+ entries = []
1570
+ for key, spec in resolved:
1571
+ if spec.get("registration") != "automatic":
1572
+ entries.append(_entry(key, spec, "manual", detail=spec.get("authNote", "")))
1573
+ continue
1574
+ add = ["hermes", "mcp", "add", key, "--url", spec["url"], "--auth", "oauth"]
1575
+ added = _shell_install(key, spec, add)
1576
+ if added["status"] in ("failed", "manual"):
1577
+ entries.append(added)
1578
+ continue
1579
+ login = ["hermes", "mcp", "login", key]
1580
+ try:
1581
+ result = subprocess.run(login, capture_output=True, text=True, timeout=60)
1582
+ except (OSError, subprocess.SubprocessError) as exc:
1583
+ entries.append(_entry(key, spec, "manual", command=login, detail=str(exc)))
1584
+ continue
1585
+ if result.returncode:
1586
+ entries.append(_entry(key, spec, "failed", command=login,
1587
+ detail=(result.stderr or result.stdout or "").strip()[:400]))
1588
+ else:
1589
+ entries.append(_entry(key, spec, "needs-auth", command=login,
1590
+ detail=spec.get("authNote", "")))
1591
+ return entries
1592
+
1593
+
1594
+ _CONNECT = {
1595
+ "claude": _connect_claude,
1596
+ "codex": _connect_codex,
1597
+ "cursor": _connect_cursor,
1598
+ "opencode": _connect_opencode,
1599
+ "hermes": _connect_hermes,
1600
+ }
1601
+
1602
+
1603
+ def _render_connect(harness, source, entries):
1604
+ lines = [f"leo setup connect — harness: {harness} (detected via {source})", ""]
1605
+ by_status = {}
1606
+ for e in entries:
1607
+ by_status.setdefault(e["status"], []).append(e)
1608
+ for status in _STATUS_ORDER:
1609
+ for e in by_status.get(status, []):
1610
+ label = STATUS_LABELS.get(status, status)
1611
+ server = e["server"] or "-"
1612
+ lines.append(f" {label:<24} {server:<16} {e['label']}")
1613
+ if e.get("command"):
1614
+ lines.append(f" $ {shlex.join(e['command'])}")
1615
+ if e.get("detail"):
1616
+ lines.extend(f" {line}" for line in e["detail"].splitlines())
1617
+ if e.get("diff"):
1618
+ lines.append(" --- paste this ---")
1619
+ lines.extend(f" {line}" for line in e["diff"].splitlines())
1620
+ lines.append("")
1621
+ return "\n".join(lines)
1622
+
1623
+
1624
+ def cmd_connect(argv):
1625
+ argv = list(argv)
1626
+ keys = []
1627
+ url_override = None
1628
+ i = 0
1629
+ while i < len(argv):
1630
+ a = argv[i]
1631
+ if a == "--url" and i + 1 < len(argv):
1632
+ url_override = argv[i + 1]
1633
+ i += 2
1634
+ continue
1635
+ if a.startswith("--url="):
1636
+ url_override = a.split("=", 1)[1]
1637
+ i += 1
1638
+ continue
1639
+ if a == "--harness" and i + 1 < len(argv):
1640
+ i += 2
1641
+ continue
1642
+ if a.startswith("--harness="):
1643
+ i += 1
1644
+ continue
1645
+ keys.append(a)
1646
+ i += 1
1647
+
1648
+ harness, source = doctor._detect_harness(argv)
1649
+ known = doctor._known_harnesses()
1650
+ if harness == "unknown" or harness not in known:
1651
+ print(_render_unsupported(harness, source))
1652
+ return 1
1653
+
1654
+ mcp = _catalogue_or_error()
1655
+ if mcp is None:
1656
+ return 1
1657
+ catalogue = {c["key"]: c for c in (mcp.get("connectors") or [])}
1658
+ if not keys:
1659
+ print(f"setup: connect needs at least one connector key "
1660
+ f"(known: {', '.join(sorted(catalogue))})", file=sys.stderr)
1661
+ return 1
1662
+ unknown_keys = [k for k in keys if k not in catalogue]
1663
+ if unknown_keys:
1664
+ print(f"setup: unknown connector key(s): {', '.join(unknown_keys)} "
1665
+ f"(known: {', '.join(sorted(catalogue))})", file=sys.stderr)
1666
+ return 1
1667
+ if url_override and len(keys) != 1:
1668
+ print("setup: --url applies to exactly one connector key at a time", file=sys.stderr)
1669
+ return 1
1670
+
1671
+ resolved = []
1672
+ for key in keys:
1673
+ spec = dict(catalogue[key])
1674
+ if url_override:
1675
+ spec["url"] = url_override
1676
+ if not spec.get("url"):
1677
+ print(f"setup: connect {key}: no default URL on record for this connector — "
1678
+ "its endpoint cannot be guessed. Pass --url <URL> yourself. "
1679
+ f"({spec.get('authNote', '')})", file=sys.stderr)
1680
+ return 1
1681
+ resolved.append((key, spec))
1682
+
1683
+ manual = [(key, spec) for key, spec in resolved if spec.get("registration") == "manual"]
1684
+ manual_entries = [_entry(key, spec, "manual", detail="Prerequisites: " + "; ".join(spec["prerequisites"]))
1685
+ for key, spec in manual]
1686
+ automatic = [(key, spec) for key, spec in resolved if spec.get("registration") == "automatic"]
1687
+ if not automatic:
1688
+ print(_render_connect(harness, source, manual_entries))
1689
+ return 0
1690
+
1691
+ # An unreadable registry is neither "present" nor "absent". Refusing
1692
+ # here prevents a blind duplicate registration when a harness CLI changed
1693
+ # shape or a hand-edited JSON/JSONC file is malformed.
1694
+ names, by_url, detection_available = _REGISTERED.get(harness, lambda: (set(), {}, False))()
1695
+ if not detection_available:
1696
+ print("leo setup connect: registered-server state is unknown; refusing to write blindly.",
1697
+ file=sys.stderr)
1698
+ return 1
1699
+
1700
+ installer = _CONNECT.get(harness)
1701
+ if installer is None:
1702
+ print(f"leo setup connect: {harness!r} has no installer wired up yet.")
1703
+ return 1
1704
+ entries = manual_entries + installer(automatic)
1705
+ print(_render_connect(harness, source, entries))
1706
+ if any(e.get("status") in ("error", "failed") for e in entries):
1707
+ return 1
1708
+ return 0
1709
+
1710
+
128
1711
  def main(argv):
129
1712
  argv = list(argv)
1713
+ if argv and argv[0] == "apply":
1714
+ return cmd_apply(argv[1:])
1715
+
1716
+ if argv and argv[0] == "connectors":
1717
+ return cmd_connectors(argv[1:])
1718
+
1719
+ if argv and argv[0] == "connect":
1720
+ return cmd_connect(argv[1:])
1721
+
130
1722
  if argv and argv[0] in ("enable", "disable"):
131
1723
  if len(argv) < 2:
132
1724
  print(f"setup: {argv[0]} needs a feature name: {', '.join(sorted(FEATURES))}",