subcortex 0.3.0__py3-none-any.whl

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 (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. subcortex-0.3.0.dist-info/top_level.txt +1 -0
subcortex/cli.py ADDED
@@ -0,0 +1,809 @@
1
+ """subcortex command line: serve / decide / stats / doctor / hook / install / uninstall / status / mcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from . import __version__, daemon, localhttp
16
+ from .backends import get_backend
17
+ from .config import load_config, log_path, pid_path
18
+
19
+
20
+ def _http(cfg: Dict[str, Any], method: str, path: str, payload: Optional[Dict[str, Any]] = None,
21
+ timeout: float = 30.0) -> Dict[str, Any]:
22
+ """JSON from the local daemon (direct socket: never through a proxy)."""
23
+ status, body = localhttp.request(int(cfg["port"]), method, path, payload, timeout)
24
+ if not isinstance(body, dict):
25
+ raise localhttp.LocalHTTPError(f"daemon answered {status} without a JSON object")
26
+ return body
27
+
28
+
29
+ def _health(cfg: Dict[str, Any], timeout: float = 2.0) -> Optional[Dict[str, Any]]:
30
+ try:
31
+ data = _http(cfg, "GET", "/health", timeout=timeout)
32
+ return data if data.get("ok") else None
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ def _daemon_interpreter() -> str:
38
+ from .provision import daemon_python
39
+
40
+ return daemon_python()
41
+
42
+
43
+ def _spawn_daemon(cfg: Dict[str, Any]) -> subprocess.Popen:
44
+ """Spawn a detached background daemon (new session, logs to daemon.log)."""
45
+ from .config import data_dir
46
+ from .provision import daemon_argv
47
+
48
+ log_path().parent.mkdir(mode=0o700, parents=True, exist_ok=True)
49
+ with open(log_path(), "ab") as log:
50
+ # Isolated and started from the data dir: see provision.daemon_argv.
51
+ return subprocess.Popen(
52
+ daemon_argv(_daemon_interpreter()), cwd=str(data_dir()),
53
+ stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
54
+ start_new_session=True, env=dict(os.environ),
55
+ )
56
+
57
+
58
+ def _wait_for_health(cfg: Dict[str, Any], timeout_s: float = 30.0) -> Optional[Dict[str, Any]]:
59
+ deadline = time.time() + timeout_s
60
+ while time.time() < deadline:
61
+ health = _health(cfg)
62
+ if health:
63
+ return health
64
+ time.sleep(0.2)
65
+ return None
66
+
67
+
68
+ def _ensure_daemon(cfg: Dict[str, Any]) -> bool:
69
+ if _health(cfg):
70
+ return True
71
+ print(f"daemon not running on port {cfg['port']}; starting it...", file=sys.stderr)
72
+ _spawn_daemon(cfg)
73
+ return _wait_for_health(cfg) is not None
74
+
75
+
76
+ # -- subcommands -----------------------------------------------------------------
77
+
78
+
79
+ def cmd_serve(args: argparse.Namespace) -> int:
80
+ cfg = load_config()
81
+ if args.stop:
82
+ return _stop_daemon()
83
+ if args.foreground:
84
+ return daemon.run() # loads and watches the config itself
85
+ health = _health(cfg)
86
+ if health:
87
+ print(f"subcortex daemon already running on 127.0.0.1:{cfg['port']} "
88
+ f"(backend {health.get('backend')})")
89
+ return 0
90
+ proc = _spawn_daemon(cfg)
91
+ if _wait_for_health(cfg):
92
+ print(f"subcortex daemon started: pid {proc.pid}, port {cfg['port']} "
93
+ f"(log: {log_path()})")
94
+ return 0
95
+ print(f"daemon (pid {proc.pid}) did not come up within 30s; check {log_path()}",
96
+ file=sys.stderr)
97
+ return 1
98
+
99
+
100
+ def _daemon_running() -> bool:
101
+ """True when some process holds the daemon's single-instance lock."""
102
+ import fcntl
103
+
104
+ from .config import lock_path
105
+
106
+ try:
107
+ with open(lock_path(), "a") as fd:
108
+ try:
109
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
110
+ except OSError:
111
+ return True
112
+ fcntl.flock(fd, fcntl.LOCK_UN)
113
+ return False
114
+ except OSError:
115
+ return False
116
+
117
+
118
+ def _stop_daemon() -> int:
119
+ """SIGTERM the daemon, but only after confirming the PID is really it: a
120
+ stale PID file may name a process that has nothing to do with us."""
121
+ cfg = load_config()
122
+ try:
123
+ pid = int(pid_path().read_text().strip())
124
+ except (OSError, ValueError):
125
+ pid = None
126
+ if not _daemon_running():
127
+ if pid is not None:
128
+ print(f"no daemon is running; removing the stale PID file (pid {pid} left alone)")
129
+ try:
130
+ pid_path().unlink()
131
+ except OSError:
132
+ pass
133
+ return 0
134
+ print("no daemon is running", file=sys.stderr)
135
+ return 1
136
+ health = _health(cfg)
137
+ if pid is None or not health or health.get("pid") != pid:
138
+ print("a daemon holds the lock but its PID can't be confirmed "
139
+ f"(PID file: {pid}, daemon says: {health.get('pid') if health else 'no answer'}); "
140
+ "not signalling anything", file=sys.stderr)
141
+ return 1
142
+ try:
143
+ os.kill(pid, signal.SIGTERM)
144
+ except ProcessLookupError:
145
+ return 0
146
+ for _ in range(50):
147
+ if not _daemon_running():
148
+ break
149
+ time.sleep(0.1)
150
+ print(f"stopped subcortex daemon (pid {pid})")
151
+ return 0
152
+
153
+
154
+ def cmd_decide(args: argparse.Namespace) -> int:
155
+ cfg = load_config()
156
+ try:
157
+ questions = json.loads(args.questions)
158
+ except ValueError:
159
+ print("--questions must be a JSON object", file=sys.stderr)
160
+ return 2
161
+ try:
162
+ state: Any = json.loads(args.state)
163
+ except ValueError:
164
+ state = args.state # plain string state is fine
165
+ if not _ensure_daemon(cfg):
166
+ print("could not reach or start the daemon", file=sys.stderr)
167
+ return 1
168
+ payload: Dict[str, Any] = {"state": state, "questions": questions}
169
+ if args.backend:
170
+ payload["backend"] = args.backend
171
+ try:
172
+ resp = _http(cfg, "POST", "/decide", payload)
173
+ except Exception as exc:
174
+ print(f"decide request failed: {exc}", file=sys.stderr)
175
+ return 1
176
+ print(json.dumps(resp, indent=2))
177
+ return 0 if resp.get("success") else 1
178
+
179
+
180
+ def cmd_stats(args: argparse.Namespace) -> int:
181
+ """What subcortex did (from the ledger) and what it cost."""
182
+ from . import ledger
183
+
184
+ since = time.time() - args.days * 86400 if getattr(args, "days", None) else None
185
+ report = ledger.summary(since)
186
+ cfg = load_config()
187
+ try:
188
+ report["daemon"] = _http(cfg, "GET", "/stats", timeout=2)
189
+ except Exception:
190
+ report["daemon"] = None
191
+ if getattr(args, "json", False):
192
+ print(json.dumps(report, indent=2))
193
+ return 0
194
+ total = report["total"]
195
+ window = f"last {args.days:g} days" if since else "since the first recorded event"
196
+ print(f"subcortex {__version__} — {window}\n")
197
+ print(f" prompt hints delivered {total['hints']}")
198
+ print(f" tool outputs trimmed {total['trims']} "
199
+ f"(~{total['chars_removed'] // 4:,} tokens of context saved, {total['chars_removed']:,} chars)")
200
+ print(f" compaction restores {total['restores']}")
201
+ if total["jev_calls"]:
202
+ print(f" jev decisions {total['jev_calls']} "
203
+ f"({total['jev_input_tokens']:,} input tokens, ${total['jev_cost_usd']:.4f})")
204
+ if report["per_tui"]:
205
+ print("\n per TUI:")
206
+ for tui, row in sorted(report["per_tui"].items()):
207
+ print(f" {tui:18s} hints {row['hints']:<5} trims {row['trims']:<5} "
208
+ f"~{row['chars_removed'] // 4:,} tokens saved restores {row['restores']}")
209
+ daemon_stats = report["daemon"]
210
+ if daemon_stats:
211
+ print(f"\n daemon up {daemon_stats.get('uptime_s', 0):.0f}s; "
212
+ f"model {daemon_stats.get('latest', {}).get('jev_model', cfg.get('backend'))}")
213
+ else:
214
+ print(f"\n daemon not running on 127.0.0.1:{cfg['port']} (it starts with the next hook)")
215
+ return 0
216
+
217
+
218
+ def cmd_eval(args: argparse.Namespace) -> int:
219
+ """Run the labeled examples through the configured backend and rules.
220
+
221
+ A decision model's probabilities are not accuracies: this is how to know
222
+ what the thresholds do on *your* backend (docs/calibration.md). Exits 1 if
223
+ a complex request would be hinted or a needed output trimmed.
224
+ """
225
+ from . import evalset, verdicts
226
+ from .backends import get_backend
227
+ from .metrics import METRICS
228
+
229
+ cfg = load_config()
230
+ backend = get_backend(cfg, args.backend) if args.backend else get_backend(cfg)
231
+ available, why = backend.available()
232
+ if not available:
233
+ print(f"backend {backend.name!r} is not usable: {why}", file=sys.stderr)
234
+ return 1
235
+ profile = verdicts._profile(backend)
236
+ cost_before = METRICS.snapshot()["totals"].get("jev_cost_usd", 0.0)
237
+ started = time.perf_counter()
238
+ judged = json.loads(json.dumps(cfg))
239
+ judged["thresholds"]["min_output_chars"] = 0 # measure the decisions, not the size gate
240
+ mistakes: List[str] = []
241
+ errors = 0
242
+
243
+ def score(label: str, prompts: Any, outputs: Any) -> None:
244
+ nonlocal errors
245
+ hinted = {True: 0, False: 0}
246
+ for prompt, simple in prompts:
247
+ verdict = verdicts.classify_prompt(prompt, backend=backend, config=cfg)
248
+ if verdict is None:
249
+ errors += 1
250
+ elif verdict["label"] == "simple":
251
+ hinted[simple] += 1
252
+ if not simple:
253
+ mistakes.append(f"{label}: hinted a complex request: {prompt!r} {verdict['signals']}")
254
+ trimmed = {True: 0, False: 0}
255
+ for task, call, output, needed in outputs:
256
+ verdict = verdicts.judge_output(output, call, backend=backend, config=judged, task=task)
257
+ if verdict is None:
258
+ errors += 1
259
+ elif verdict["needed"] is False:
260
+ trimmed[needed] += 1
261
+ if needed:
262
+ mistakes.append(f"{label}: trimmed needed output of {call!r} for {task!r} {verdict['signals']}")
263
+ n_simple = sum(1 for _, s in prompts if s)
264
+ n_disposable = sum(1 for *_, n in outputs if not n)
265
+ print(f" {label:12s} hints {hinted[True]}/{n_simple} simple, {hinted[False]}/{len(prompts) - n_simple} "
266
+ f"complex (must be 0) · trims {trimmed[False]}/{n_disposable} disposable, "
267
+ f"{trimmed[True]}/{len(outputs) - n_disposable} needed (must be 0)")
268
+
269
+ print(f"backend {backend.name} · rule {profile!r}"
270
+ + ("" if profile in verdicts.TRIMS_BY_DEFAULT or cfg["thresholds"].get("output_needed_threshold")
271
+ is not None else " · output trimming off by default for this backend"))
272
+ score("calibration", evalset.PROMPTS, evalset.OUTPUTS)
273
+ score("held out", evalset.HELDOUT_PROMPTS, evalset.HELDOUT_OUTPUTS)
274
+ print(f" {time.perf_counter() - started:.1f}s", end="")
275
+ cost = METRICS.snapshot()["totals"].get("jev_cost_usd", 0.0) - cost_before
276
+ print(f" · jev cost ${cost:.5f}" if cost else "")
277
+ if errors:
278
+ print(f" errors: {errors} decisions failed (they fail open: nothing changes)")
279
+ for mistake in mistakes:
280
+ print(f" ✗ {mistake}")
281
+ return 1 if mistakes or errors else 0
282
+
283
+
284
+ def cmd_doctor(args: argparse.Namespace) -> int:
285
+ ok = True
286
+ print(f"subcortex {__version__} doctor\n")
287
+
288
+ cfg: Dict[str, Any] = {}
289
+ try:
290
+ cfg = load_config()
291
+ print(f"[ok] config loads (backend={cfg['backend']}, port={cfg['port']}, "
292
+ f"model={cfg.get('model')})")
293
+ except Exception as exc:
294
+ print(f"[FAIL] config does not load: {exc}")
295
+ print(" fix: remove or repair ~/.config/subcortex/config.json")
296
+ return 1
297
+
298
+ try:
299
+ backend = get_backend(cfg)
300
+ available, reason = backend.available()
301
+ if available:
302
+ print(f"[ok] backend {backend.name!r}: {reason}")
303
+ else:
304
+ ok = False
305
+ print(f"[FAIL] backend {backend.name!r}: {reason}")
306
+ except Exception as exc:
307
+ ok = False
308
+ print(f"[FAIL] backend: {exc}")
309
+
310
+ health = _health(cfg)
311
+ if health:
312
+ print(f"[ok] daemon healthy on 127.0.0.1:{cfg['port']} "
313
+ f"(backend {health.get('backend')}, model {health.get('model')})")
314
+ else:
315
+ print(f"[..] daemon not running on port {cfg['port']}; starting it...")
316
+ _spawn_daemon(cfg)
317
+ health = _wait_for_health(cfg)
318
+ if health:
319
+ print(f"[ok] daemon started and healthy on 127.0.0.1:{cfg['port']}")
320
+ else:
321
+ ok = False
322
+ print(f"[FAIL] daemon did not come up; check {log_path()}")
323
+
324
+ from . import installers
325
+
326
+ wired = []
327
+ for name in installers.names():
328
+ installer = installers.get_installer(name, mcp=True)
329
+ try:
330
+ if not installer.status()["installed"]:
331
+ continue
332
+ missing = [exe for exe in installer.installed_executables() if not os.access(exe, os.X_OK)]
333
+ # Re-installing would change the files: older hook commands (no -I
334
+ # isolation) or a plugin copy from an older subcortex.
335
+ outdated = any(plan.changed for plan in installers.get_installer(name).plans())
336
+ except Exception as exc:
337
+ print(f"[FAIL] {name}: could not read its config ({exc})")
338
+ ok = False
339
+ continue
340
+ wired.append(name)
341
+ if missing:
342
+ ok = False
343
+ print(f"[FAIL] {name}: hook executable missing: {', '.join(missing)}")
344
+ print(f" fix: subcortex install {name} (rewrites the hooks for this install)")
345
+ elif outdated:
346
+ ok = False
347
+ print(f"[FAIL] {name}: installed by an older subcortex")
348
+ print(f" fix: subcortex install {name}")
349
+ else:
350
+ print(f"[ok] {name}: hooks installed")
351
+ if not wired:
352
+ print("[..] no TUI integrations installed yet (see: subcortex tuis)")
353
+
354
+ print(f"\n{'all checks passed' if ok else 'some checks failed — see fixes above'}")
355
+ return 0 if ok else 1
356
+
357
+
358
+ def _resolve_tuis(args: argparse.Namespace) -> Optional[list]:
359
+ from . import installers
360
+
361
+ requested = list(args.tuis or []) + ([args.tui] if getattr(args, "tui", None) else [])
362
+ if not requested:
363
+ from .ui import UI, Cancelled
364
+ from .wizard import pick_tuis, tui_rows
365
+
366
+ ui = UI()
367
+ if not ui.interactive:
368
+ print("name at least one TUI, 'detected' or 'all' (see: subcortex tuis)", file=sys.stderr)
369
+ return None
370
+ rows = tui_rows()
371
+ if args.command == "uninstall":
372
+ rows = [r for r in rows if r["installed"]]
373
+ if not rows:
374
+ print("subcortex isn't installed in any TUI")
375
+ return []
376
+ preselected = set()
377
+ else:
378
+ preselected = {r["name"] for r in rows if r["detected"] and not r["installed"]}
379
+ try:
380
+ return pick_tuis(ui, f"{args.command.capitalize()} which TUIs?", rows, preselected)
381
+ except Cancelled:
382
+ return []
383
+ if requested == ["all"]:
384
+ return installers.names()
385
+ if requested == ["detected"]:
386
+ found = [n for n in installers.names() if installers.get_installer(n).detected()]
387
+ if not found:
388
+ print("no supported TUI found on PATH (see: subcortex tuis)", file=sys.stderr)
389
+ return None
390
+ return found
391
+ resolved = []
392
+ for name in requested:
393
+ key = installers.canonical_name(name)
394
+ if key is None:
395
+ print(f"unknown TUI {name!r}; supported: {', '.join(installers.names())}",
396
+ file=sys.stderr)
397
+ return None
398
+ resolved.append(key)
399
+ return resolved
400
+
401
+
402
+ def _confirm(prompt: str) -> bool:
403
+ from .ui import UI, Cancelled
404
+
405
+ ui = UI()
406
+ if not ui.interactive:
407
+ return False
408
+ try:
409
+ return ui.confirm(prompt, default=False)
410
+ except Cancelled:
411
+ return False
412
+
413
+
414
+ def _print_result(result) -> None:
415
+ status = "ok" if result.ok else "FAILED"
416
+ print(f"[{status}] {result.action} {result.tui}: {', '.join(result.paths)}")
417
+ for saved in result.backups:
418
+ print(f" backup: {saved}")
419
+ for message in result.messages:
420
+ print(f" {message}")
421
+
422
+
423
+ def cmd_install(args: argparse.Namespace) -> int:
424
+ from .installers import get_installer
425
+
426
+ tuis = _resolve_tuis(args)
427
+ if tuis is None:
428
+ return 2
429
+ if args.backend:
430
+ from .config import save_config
431
+
432
+ save_config({"backend": args.backend})
433
+ print(f"backend set to {args.backend!r}")
434
+ ok = True
435
+ for tui in tuis:
436
+ installer = get_installer(tui, mcp=args.mcp)
437
+ try:
438
+ plans = installer.plans()
439
+ except Exception as exc:
440
+ print(f"[FAILED] {tui}: {exc}")
441
+ ok = False
442
+ continue
443
+ changed = [p for p in plans if p.changed]
444
+ if not changed:
445
+ print(f"[ok] {tui}: already installed ({', '.join(str(p.path) for p in plans)})")
446
+ continue
447
+ for plan in changed:
448
+ print(plan.diff() or f"(creates {plan.path})")
449
+ if not args.dry_run and not args.yes and not _confirm(f"Apply these changes for {tui}?"):
450
+ print(f"[skipped] {tui}: not confirmed (use --yes to apply non-interactively)")
451
+ ok = False
452
+ continue
453
+ result = installer.install(dry_run=args.dry_run, run_self_test=not args.no_self_test,
454
+ check_version=not args.ignore_version)
455
+ _print_result(result)
456
+ ok = ok and result.ok
457
+ return 0 if ok else 1
458
+
459
+
460
+ def cmd_uninstall(args: argparse.Namespace) -> int:
461
+ from .installers import get_installer
462
+
463
+ tuis = _resolve_tuis(args)
464
+ if tuis is None:
465
+ return 2
466
+ ok = True
467
+ for tui in tuis:
468
+ result = get_installer(tui, mcp=True).uninstall(dry_run=args.dry_run)
469
+ _print_result(result)
470
+ ok = ok and result.ok
471
+ return 0 if ok else 1
472
+
473
+
474
+ def cmd_status(args: argparse.Namespace) -> int:
475
+ from . import installers
476
+
477
+ names = installers.names() if not args.tuis else (_resolve_tuis(args) or [])
478
+ rows = [installers.get_installer(n, mcp=True).status() for n in names]
479
+ if args.json:
480
+ print(json.dumps(rows, indent=2))
481
+ return 0
482
+ width = max(len(r["tui"]) for r in rows) if rows else 10
483
+ for r in rows:
484
+ mark = "installed" if r["installed"] else "-"
485
+ if r.get("mcp_installed"):
486
+ mark += "+mcp"
487
+ found = r["detected"] or "not on PATH"
488
+ print(f"{r['tui']:<{width}} {r['seam']:<6} {mark:<13} {found}")
489
+ return 0
490
+
491
+
492
+ def cmd_wrap(args: argparse.Namespace) -> int:
493
+ """Run a command; print its output trimmed by the policy; exit with its code.
494
+
495
+ For TUIs with no hook seam (Aider: ``test-cmd: subcortex wrap -- pytest -q``).
496
+ Output is captured (stdout+stderr merged, in order) and printed once the
497
+ command exits. Anything going wrong on our side prints the output as is.
498
+ """
499
+ argv = list(args.command or [])
500
+ if argv and argv[0] == "--":
501
+ argv = argv[1:]
502
+ if not argv:
503
+ print("usage: subcortex wrap -- <command> [args...]", file=sys.stderr)
504
+ return 2
505
+ try:
506
+ proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
507
+ except OSError as exc:
508
+ print(f"subcortex wrap: {exc}", file=sys.stderr)
509
+ return 127
510
+ raw = proc.stdout
511
+ code = proc.returncode if proc.returncode >= 0 else 128 - proc.returncode # killed by a signal
512
+ replacement = None
513
+ if proc.returncode == 0 and len(raw) <= WRAP_MAX_TRIM_BYTES:
514
+ try:
515
+ from . import policy
516
+ from .client import DaemonClient
517
+
518
+ cfg = load_config()
519
+ command = " ".join(argv)
520
+ # Aider can't tell us the user's request; what this run is for is known.
521
+ task = f"Check whether `{command}` succeeds and act on anything it reports."
522
+ replacement = policy.trim_output(raw.decode("utf-8", errors="replace"), cfg,
523
+ DaemonClient(cfg).judge, tool="shell",
524
+ tool_input={"command": command}, task=task)
525
+ except Exception:
526
+ replacement = None
527
+ out = getattr(sys.stdout, "buffer", None)
528
+ if out is None: # stdout replaced by a text stream (embedding, tests)
529
+ sys.stdout.write(replacement or raw.decode("utf-8", errors="replace"))
530
+ sys.stdout.flush()
531
+ return code
532
+ # Untouched output goes through byte for byte (whatever its encoding).
533
+ out.write(replacement.encode("utf-8", errors="replace") if replacement else raw)
534
+ out.flush()
535
+ return code
536
+
537
+
538
+ WRAP_MAX_TRIM_BYTES = 32 * 1024 * 1024
539
+
540
+
541
+ def cmd_setup(args: argparse.Namespace) -> int:
542
+ from . import wizard
543
+
544
+ return wizard.run(args)
545
+
546
+
547
+ def cmd_service(args: argparse.Namespace) -> int:
548
+ from . import service
549
+
550
+ if args.action == "status":
551
+ print(json.dumps(service.status(), indent=2))
552
+ return 0
553
+ try:
554
+ notes = service.install() if args.action == "install" else service.uninstall()
555
+ except RuntimeError as exc:
556
+ print(f"[FAILED] {exc}", file=sys.stderr)
557
+ return 1
558
+ for note in notes:
559
+ print(f"[ok] {note}")
560
+ return 0
561
+
562
+
563
+ def _flatten(cfg: Dict[str, Any], prefix: str = "") -> Dict[str, Any]:
564
+ flat: Dict[str, Any] = {}
565
+ for key, value in cfg.items():
566
+ name = f"{prefix}{key}"
567
+ if isinstance(value, dict):
568
+ flat.update(_flatten(value, name + "."))
569
+ else:
570
+ flat[name] = value
571
+ return flat
572
+
573
+
574
+ def _coerce(key: str, raw: str) -> Any:
575
+ """Parse ``raw`` as the type of ``key``'s default; ValueError if impossible."""
576
+ from .config import DEFAULT_CONFIG
577
+
578
+ defaults = _flatten(DEFAULT_CONFIG)
579
+ defaults.setdefault("daemon_python", "")
580
+ if key not in defaults:
581
+ raise ValueError(f"unknown setting {key!r}; see: subcortex config show")
582
+ default = defaults[key]
583
+ if isinstance(default, bool):
584
+ lowered = raw.strip().lower()
585
+ if lowered in ("1", "true", "yes", "on"):
586
+ return True
587
+ if lowered in ("0", "false", "no", "off"):
588
+ return False
589
+ raise ValueError(f"{key} takes true/false")
590
+ if isinstance(default, (int, float)) or (default is None and key.startswith("thresholds.")):
591
+ try:
592
+ value = float(raw) if default is None else type(default)(raw)
593
+ except ValueError:
594
+ raise ValueError(f"{key} takes a number") from None
595
+ if default is None and not 0.0 <= value <= 1.0:
596
+ raise ValueError(f"{key} is a probability between 0 and 1")
597
+ return value
598
+ if key == "backend" and raw not in ("laya", "jev"):
599
+ raise ValueError("backend is laya or jev")
600
+ return raw
601
+
602
+
603
+ def _set_dotted(key: str, value: Any) -> None:
604
+ from .config import save_config
605
+
606
+ update: Dict[str, Any] = {}
607
+ node = update
608
+ parts = key.split(".")
609
+ for part in parts[:-1]:
610
+ node = node.setdefault(part, {})
611
+ node[parts[-1]] = value
612
+ save_config(update)
613
+
614
+
615
+ def cmd_config(args: argparse.Namespace) -> int:
616
+ from .config import config_path, secrets_path, unset_config
617
+
618
+ action = args.action or "show"
619
+ cfg = load_config()
620
+ flat = _flatten(cfg)
621
+ if action == "show":
622
+ print(f"# {config_path()} (defaults < file < SUBCORTEX_* env)")
623
+ for key, value in sorted(flat.items()):
624
+ print(f"{key} = {json.dumps(value)}")
625
+ if secrets_path().is_file():
626
+ try:
627
+ names = sorted(json.loads(secrets_path().read_text()))
628
+ except (OSError, ValueError):
629
+ names = []
630
+ print(f"# secrets stored in {secrets_path()}: {', '.join(names) or 'none'}")
631
+ return 0
632
+ if action == "get":
633
+ if args.key not in flat:
634
+ print(f"unknown setting {args.key!r}", file=sys.stderr)
635
+ return 2
636
+ print(json.dumps(flat[args.key]))
637
+ return 0
638
+ if action == "set":
639
+ if args.value is None:
640
+ print("usage: subcortex config set <key> <value>", file=sys.stderr)
641
+ return 2
642
+ try:
643
+ value = _coerce(args.key, args.value)
644
+ except ValueError as exc:
645
+ print(str(exc), file=sys.stderr)
646
+ return 2
647
+ _set_dotted(args.key, value)
648
+ print(f"{args.key} = {json.dumps(value)}")
649
+ return 0
650
+ if action == "unset":
651
+ print(f"{args.key} reverted to default" if unset_config(args.key) else f"{args.key} was not set")
652
+ return 0
653
+ # edit: interactive
654
+ from .ui import UI, Cancelled
655
+
656
+ ui = UI()
657
+ if not ui.interactive:
658
+ print("config edit needs a terminal; use: subcortex config set <key> <value>", file=sys.stderr)
659
+ return 2
660
+ try:
661
+ while True:
662
+ flat = _flatten(load_config())
663
+ options = [(k, k, json.dumps(v)) for k, v in sorted(flat.items())] + [(None, "done", "")]
664
+ key = ui.choose("Change which setting?", options, len(options) - 1)
665
+ if key is None:
666
+ return 0
667
+ while True:
668
+ raw = ui.ask(key, json.dumps(flat[key]).strip('"'))
669
+ try:
670
+ _set_dotted(key, _coerce(key, raw))
671
+ break
672
+ except ValueError as exc:
673
+ ui.warn(str(exc))
674
+ except Cancelled:
675
+ return 130
676
+
677
+
678
+ def cmd_mcp(args: argparse.Namespace) -> int:
679
+ from . import mcp_server
680
+ mcp_server.serve()
681
+ return 0
682
+
683
+
684
+ # -- parser -----------------------------------------------------------------------
685
+
686
+
687
+ def build_parser() -> argparse.ArgumentParser:
688
+ parser = argparse.ArgumentParser(
689
+ prog="subcortex",
690
+ description="Local decision layer for coding-agent TUIs",
691
+ )
692
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
693
+ sub = parser.add_subparsers(dest="command", required=True)
694
+
695
+ p_setup = sub.add_parser("setup", help="interactive setup: backend, behaviors, TUIs, daemon")
696
+ p_setup.add_argument("--yes", "-y", action="store_true", help="accept every default (unattended)")
697
+ p_setup.add_argument("--backend", choices=["laya", "jev"])
698
+ p_setup.add_argument("--model", choices=["multilingual", "english", "typed-decisions"])
699
+ p_setup.add_argument("--tuis", nargs="+", metavar="TUI",
700
+ help="TUIs to wire (names, 'detected', 'all' or 'none'); default: ask / detected")
701
+ p_setup.add_argument("--mcp", action="store_true", help="also register the MCP server where supported")
702
+ p_setup.add_argument("--service", dest="service", action="store_true", default=None,
703
+ help="start the daemon at login")
704
+ p_setup.add_argument("--no-service", dest="service", action="store_false")
705
+ p_setup.add_argument("--skip-backend-install", action="store_true",
706
+ help="don't install the laya package")
707
+ p_setup.add_argument("--ignore-version", action="store_true", help=argparse.SUPPRESS)
708
+ p_setup.set_defaults(func=cmd_setup)
709
+
710
+
711
+ p_serve = sub.add_parser("serve", help="run the daemon (background by default)")
712
+ p_serve.add_argument("--foreground", action="store_true",
713
+ help="run in-process instead of spawning a detached daemon")
714
+ p_serve.add_argument("--stop", action="store_true", help="stop the running daemon")
715
+ p_serve.set_defaults(func=cmd_serve)
716
+
717
+ p_decide = sub.add_parser("decide", help="one-shot typed decision via the daemon")
718
+ p_decide.add_argument("--state", required=True, help="state (JSON or plain string)")
719
+ p_decide.add_argument("--questions", required=True, help="questions as a JSON object")
720
+ p_decide.add_argument("--backend", choices=["laya", "jev"], default=None,
721
+ help="override the configured backend for this call")
722
+ p_decide.set_defaults(func=cmd_decide)
723
+
724
+ p_stats = sub.add_parser("stats", help="what subcortex did: hints, trims (tokens saved), restores, jev cost")
725
+ p_stats.add_argument("--days", type=float, default=None, help="only the last N days")
726
+ p_stats.add_argument("--json", action="store_true", help="machine-readable output")
727
+ p_stats.set_defaults(func=cmd_stats)
728
+
729
+ p_eval = sub.add_parser("eval", help="run the labeled examples through your backend and thresholds")
730
+ p_eval.add_argument("--backend", choices=["laya", "jev"], help="evaluate this backend instead")
731
+ p_eval.set_defaults(func=cmd_eval)
732
+
733
+ p_doctor = sub.add_parser("doctor", help="check config, backend and daemon health")
734
+ p_doctor.set_defaults(func=cmd_doctor)
735
+
736
+ # `hook` is dispatched in main() before argparse runs (see there); this
737
+ # entry only documents it in --help.
738
+ sub.add_parser("hook", help="TUI hook entry point: subcortex hook <tui> [event] (payload on stdin)")
739
+
740
+ for name, func, help_text in (
741
+ ("install", cmd_install, "wire subcortex into one or more TUIs"),
742
+ ("uninstall", cmd_uninstall, "remove subcortex from one or more TUIs"),
743
+ ):
744
+ p = sub.add_parser(name, help=help_text)
745
+ p.add_argument("tuis", nargs="*", metavar="TUI",
746
+ help="TUI name(s), 'detected' (those on PATH) or 'all'")
747
+ p.add_argument("--tui", help=argparse.SUPPRESS) # 0.1.0 spelling
748
+ p.add_argument("--dry-run", action="store_true", help="show the change, write nothing")
749
+ if name == "install":
750
+ p.add_argument("--backend", choices=["laya", "jev"], default=None,
751
+ help="decision backend (default: keep configured one)")
752
+ p.add_argument("--yes", "-y", action="store_true", help="apply without asking")
753
+ p.add_argument("--no-self-test", action="store_true",
754
+ help="skip running the hook commands before writing them")
755
+ p.add_argument("--ignore-version", action="store_true",
756
+ help="install even if the detected TUI version is too old for its hooks")
757
+ p.add_argument("--mcp", action="store_true",
758
+ help="also register the `subcortex mcp` server where the TUI supports it")
759
+ p.set_defaults(func=func)
760
+
761
+ p_status = sub.add_parser("status", aliases=["tuis"], help="list supported TUIs and install state")
762
+ p_status.add_argument("tuis", nargs="*", metavar="TUI")
763
+ p_status.add_argument("--json", action="store_true")
764
+ p_status.set_defaults(func=cmd_status, tui=None)
765
+
766
+ p_wrap = sub.add_parser("wrap", help="run a command and trim its disposable output (for TUIs without hooks)")
767
+ p_wrap.add_argument("command", nargs=argparse.REMAINDER, help="-- <command> [args...]")
768
+ p_wrap.set_defaults(func=cmd_wrap)
769
+
770
+ p_service = sub.add_parser("service", help="start the daemon at login (launchd / systemd --user)")
771
+ p_service.add_argument("action", choices=["install", "uninstall", "status"])
772
+ p_service.set_defaults(func=cmd_service)
773
+
774
+ p_config = sub.add_parser("config", help="show or change settings")
775
+ p_config.add_argument("action", nargs="?", choices=["show", "get", "set", "unset", "edit"])
776
+ p_config.add_argument("key", nargs="?")
777
+ p_config.add_argument("value", nargs="?")
778
+ p_config.set_defaults(func=cmd_config)
779
+
780
+ p_mcp = sub.add_parser("mcp", help="run the stdio MCP server")
781
+ p_mcp.set_defaults(func=cmd_mcp)
782
+
783
+ return parser
784
+
785
+
786
+ def main(argv: Optional[list] = None) -> int:
787
+ argv = list(sys.argv[1:] if argv is None else argv)
788
+ if argv and argv[0] == "hook":
789
+ # Hooks bypass argparse entirely: a usage error would exit 2, which
790
+ # several TUIs treat as "block this prompt". hook.main always returns 0.
791
+ from .hook import main as hook_main
792
+
793
+ return hook_main(argv[1:])
794
+ parser = build_parser()
795
+ if not argv:
796
+ parser.print_help()
797
+ print("\nGet started: subcortex setup")
798
+ return 0
799
+ args = parser.parse_args(argv)
800
+ if args.command == "config" and args.action in ("get", "set", "unset") and not args.key:
801
+ parser.error(f"config {args.action} needs a key")
802
+ try:
803
+ return int(args.func(args))
804
+ except KeyboardInterrupt:
805
+ return 130
806
+
807
+
808
+ if __name__ == "__main__":
809
+ sys.exit(main())