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.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- 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())
|