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.
- package/LICENSE +21 -0
- package/README.md +14 -7
- package/adapters/cursor/agents/executor.md +1 -1
- package/adapters/cursor/agents/implementer.md +2 -2
- package/adapters/cursor/agents/review-lens.md +22 -0
- package/adapters/cursor/agents/reviewer.md +2 -2
- package/adapters/opencode/agents.json +43 -4
- package/adapters/opencode/plugin.js +325 -37
- package/config/MCP_PINS.md +17 -0
- package/config/models.json +276 -8
- package/hooks/bash-guard.py +51 -9
- package/package.json +3 -6
- package/roles/executor.md +1 -1
- package/roles/implementer.md +2 -2
- package/roles/review-lens.md +20 -0
- package/roles/reviewer.md +2 -2
- package/scripts/doctor.py +267 -31
- package/scripts/ghreview.py +7 -3
- package/scripts/jsonc_bridge.cjs +23 -0
- package/scripts/memory.py +74 -35
- package/scripts/render_adapters.py +57 -22
- package/scripts/resolve_attach_target.py +45 -13
- package/scripts/setup.py +1594 -2
- package/skills/brainstorming/SKILL.md +3 -1
- package/skills/debugging/SKILL.md +4 -2
- package/skills/delegation/SKILL.md +10 -8
- package/skills/doctor/SKILL.md +33 -14
- package/skills/executing-plans/SKILL.md +2 -1
- package/skills/finishing-a-branch/SKILL.md +4 -2
- package/skills/freshness/SKILL.md +23 -10
- package/skills/memory/SKILL.md +12 -2
- package/skills/resolve-ticket/SKILL.md +15 -9
- package/skills/review-pr/SKILL.md +26 -16
- package/skills/setup/SKILL.md +123 -9
- package/skills/setup/agents/openai.yaml +5 -0
- package/skills/test-first/SKILL.md +3 -1
- package/skills/using-leo/SKILL.md +11 -6
- package/skills/using-leo/references/claude-mapping.md +2 -1
- package/skills/using-leo/references/codex-mapping.md +4 -5
- package/skills/using-leo/references/cursor-mapping.md +2 -1
- package/skills/using-leo/references/hermes-mapping.md +2 -1
- package/skills/using-leo/references/opencode-mapping.md +6 -3
- package/skills/verification/SKILL.md +2 -1
- package/skills/visual-verification/SKILL.md +2 -1
- package/skills/watch-review/SKILL.md +17 -14
- package/skills/watch-review/agents/openai.yaml +5 -0
- package/skills/worktrees/SKILL.md +3 -1
- package/skills/writing-plans/SKILL.md +2 -1
- package/skills/writing-skills/SKILL.md +9 -2
- package/vendor/jsonc-parser-3.3.1/LICENSE.md +21 -0
- package/vendor/jsonc-parser-3.3.1/README.md +26 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +201 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +275 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +682 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +456 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +42 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +351 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +194 -0
- package/vendor/jsonc-parser-3.3.1/package.json +37 -0
- 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
|
-
|
|
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
|
|
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))}",
|