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/wizard.py ADDED
@@ -0,0 +1,474 @@
1
+ """``subcortex setup``: the interactive installer.
2
+
3
+ Five steps — backend, behaviors, TUIs, review & install, daemon — each saved
4
+ as soon as it is confirmed, so cancelling part-way keeps what was agreed to
5
+ and changes nothing else. Every choice has a flag, and ``--yes`` accepts the
6
+ defaults, so the same flow runs unattended.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import contextlib
13
+ import io
14
+ import json
15
+ import sys
16
+ import time
17
+ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
18
+
19
+ from . import __version__, installers, provision, service
20
+ from .config import load_config, read_secret, save_config, save_secret, secrets_path
21
+ from .installers.base import InstallError, Plan
22
+ from .ui import UI, Cancelled
23
+
24
+ STEPS = 5
25
+ FEATURES = [
26
+ ("prompt_hint", "Prompt hints", "tell the main model when a request looks simple"),
27
+ ("trim_output", "Output trimming", "cut large, disposable shell output to head + tail"),
28
+ ("compaction_snapshot", "Compaction snapshots", "keep the last messages across context compaction"),
29
+ ]
30
+ MODELS = [
31
+ ("multilingual", "multilingual", "default · many languages"),
32
+ ("english", "english", "English only · smallest"),
33
+ ("typed-decisions", "typed-decisions", "tuned for typed decisions"),
34
+ ]
35
+
36
+
37
+ def capabilities(name: str) -> str:
38
+ """Short 'hint · trim · compaction' summary of what subcortex does in a TUI."""
39
+ from .adapters import get_adapter
40
+ from .adapters.base import PRE_COMPACT, PROMPT, TOOL_OUTPUT
41
+
42
+ installer = installers.get_installer(name)
43
+ if installer.seam == "plugin":
44
+ return "hint · trim · compaction"
45
+ if installer.seam == "mcp":
46
+ return "on-demand tools"
47
+ adapter = get_adapter(name)
48
+ kinds = set(adapter.events.values()) if adapter else set()
49
+ parts = [label for kind, label in ((PROMPT, "hint"), (TOOL_OUTPUT, "trim"), (PRE_COMPACT, "compaction"))
50
+ if kind in kinds]
51
+ return " · ".join(parts) or "—"
52
+
53
+
54
+ def tui_rows() -> List[Dict[str, Any]]:
55
+ rows = []
56
+ for name in installers.names():
57
+ installer = installers.get_installer(name, mcp=True)
58
+ try:
59
+ installed = bool(installer.status()["installed"])
60
+ except Exception:
61
+ installed = False
62
+ rows.append({"name": name, "display": installer.display_name, "seam": installer.seam,
63
+ "detected": installer.detected(), "installed": installed,
64
+ "mcp": installer.supports_mcp})
65
+ rows.sort(key=lambda r: (not (r["detected"] or r["installed"]), r["display"].lower()))
66
+ return rows
67
+
68
+
69
+ def pick_tuis(ui: UI, question: str, rows: Sequence[Dict[str, Any]], preselected: Set[str]) -> List[str]:
70
+ options = []
71
+ for row in rows:
72
+ state = "installed" if row["installed"] else ("found" if row["detected"] else "not found")
73
+ options.append((row["name"], row["display"], f"{row['seam']} · {capabilities(row['name'])} · {state}"))
74
+ return ui.checklist(question, options, preselected)
75
+
76
+
77
+ def _diffstat(plan: Plan) -> str:
78
+ added = removed = 0
79
+ for line in plan.diff().splitlines():
80
+ if line.startswith("+") and not line.startswith("+++"):
81
+ added += 1
82
+ elif line.startswith("-") and not line.startswith("---"):
83
+ removed += 1
84
+ action = "create" if not plan.before else ("delete" if not plan.after else "edit")
85
+ return f"{action} {plan.path} (+{added} −{removed})"
86
+
87
+
88
+ class Wizard:
89
+ def __init__(self, ui: UI, args: argparse.Namespace) -> None:
90
+ self.ui = ui
91
+ self.args = args
92
+ self.yes = bool(getattr(args, "yes", False))
93
+ self.next_steps: List[str] = []
94
+
95
+ # -- helpers ---------------------------------------------------------------------------
96
+
97
+ def confirm(self, question: str, default: bool) -> bool:
98
+ return default if self.yes else self.ui.confirm(question, default)
99
+
100
+ def choose(self, question: str, options, default: int = 0):
101
+ return options[default][0] if self.yes else self.ui.choose(question, options, default)
102
+
103
+ # -- flow --------------------------------------------------------------------------------
104
+
105
+ def run(self) -> int:
106
+ ui = self.ui
107
+ ui.title(f"subcortex {__version__} setup")
108
+ ui.dim("A small local decision model that saves your coding TUIs tokens. "
109
+ "Nothing is written until you confirm it.")
110
+ try:
111
+ self.step_backend()
112
+ self.step_features()
113
+ selected, remove = self.step_tuis()
114
+ self.step_install(selected, remove)
115
+ self.step_daemon()
116
+ except Cancelled:
117
+ ui.write()
118
+ ui.warn("setup cancelled — steps you already confirmed stay applied; nothing else was changed")
119
+ return 130
120
+ self.summary()
121
+ return 0
122
+
123
+ # -- 1. backend --------------------------------------------------------------------------
124
+
125
+ def step_backend(self) -> None:
126
+ ui = self.ui
127
+ ui.step(1, STEPS, "Decision backend")
128
+ cfg = load_config()
129
+ backend = getattr(self.args, "backend", None)
130
+ if not backend:
131
+ options = [("laya", "Laya — local model", f"private, offline, free · {provision.laya_package()}"),
132
+ ("jev", "Jev — hosted API", "nothing to install locally · needs an API key")]
133
+ backend = self.choose("Which decision backend?", options, 0 if cfg["backend"] == "laya" else 1)
134
+ save_config({"backend": backend})
135
+ if backend == "laya":
136
+ self.setup_laya()
137
+ else:
138
+ self.setup_jev()
139
+
140
+ def setup_laya(self) -> None:
141
+ ui = self.ui
142
+ cfg = load_config()
143
+ model = getattr(self.args, "model", None)
144
+ if not model:
145
+ current = next((i for i, m in enumerate(MODELS) if m[0] == cfg.get("model")), 0)
146
+ model = self.choose("Which Laya model?", MODELS, current)
147
+ save_config({"model": model})
148
+
149
+ python = provision.daemon_python()
150
+ ready, why = provision.backend_ready(python)
151
+ if ready:
152
+ ui.ok(why)
153
+ else:
154
+ ui.warn(why)
155
+ if getattr(self.args, "skip_backend_install", False):
156
+ self.next_steps.append("install the laya backend: subcortex setup")
157
+ return
158
+ options = [("dedicated", "Install into subcortex's own environment", f"{provision.venv_dir()} · recommended"),
159
+ ("current", "Install into this Python", sys.executable),
160
+ ("skip", "Skip for now", "hooks pass through until the backend is installed")]
161
+ where = self.choose(f"Where should {provision.laya_package()} go?", options, 0)
162
+ if where == "skip":
163
+ self.next_steps.append("install the laya backend: subcortex setup")
164
+ return
165
+ if where == "dedicated":
166
+ foreign = provision.venv_is_foreign()
167
+ if foreign:
168
+ ui.info(f"{provision.venv_dir()} links to {foreign}.")
169
+ ui.dim("It will be replaced by a dedicated environment; the linked one is left untouched.")
170
+ if not self.confirm("Replace the link?", True):
171
+ self.next_steps.append("install the laya backend: subcortex setup")
172
+ return
173
+ with ui.spinner("creating the environment"):
174
+ ok, message = provision.create_venv(replace=bool(foreign))
175
+ if not ok:
176
+ ui.error(f"could not create the environment: {message}")
177
+ self.next_steps.append("install the laya backend: subcortex setup")
178
+ return
179
+ python = str(provision.venv_python())
180
+ else:
181
+ python = sys.executable
182
+ with ui.spinner(f"installing {provision.laya_package()} and subcortex (a few minutes the first time)"):
183
+ ok, tail = provision.install_laya(python)
184
+ if not ok:
185
+ ui.error("pip failed:")
186
+ for line in tail.splitlines()[-8:]:
187
+ ui.dim(line)
188
+ self.next_steps.append("install the laya backend: subcortex setup")
189
+ return
190
+ ready, why = provision.backend_ready(python)
191
+ (ui.ok if ready else ui.warn)(why)
192
+ if not ready:
193
+ return
194
+ save_config({"daemon_python": python})
195
+ if self.confirm("Download and load the model now? (first time only; a few hundred MB)", True):
196
+ self.warm_up()
197
+
198
+ def warm_up(self) -> None:
199
+ ui = self.ui
200
+ cfg = load_config()
201
+ if not self.restart_daemon():
202
+ ui.error("the daemon did not start; see `subcortex doctor`")
203
+ return
204
+ port = int(cfg["port"])
205
+ try:
206
+ with ui.spinner("downloading and loading the model"):
207
+ _post(port, "/verdict/prompt", {"prompt": "what is 2+2?"}, timeout=1800)
208
+ started = time.perf_counter()
209
+ body = _post(port, "/verdict/prompt", {"prompt": "rename foo to bar"}, timeout=60)
210
+ ms = (time.perf_counter() - started) * 1000
211
+ except Exception as exc:
212
+ ui.error(f"the model did not answer: {exc}")
213
+ return
214
+ if body.get("success"):
215
+ ui.ok(f"model ready — a decision now takes {ms:.0f} ms")
216
+ else:
217
+ ui.error(f"the backend failed: {body.get('error')}")
218
+
219
+ def setup_jev(self) -> None:
220
+ ui = self.ui
221
+ jev = load_config()["jev"]
222
+ ui.dim("Sent to the Jev API per decision: your latest request, the tool call, and a "
223
+ "~1.5 KB head+tail excerpt of large outputs, with anything that looks like a "
224
+ "secret masked. Billed per input token ($0.042 per million); `subcortex stats` "
225
+ "shows the running total.")
226
+ if not self.yes and self.ui.confirm(f"Use the default endpoint ({jev['base_url']}{jev['endpoint_path']})?", True) is False:
227
+ from .backends import jev as jev_backend
228
+
229
+ def valid_url(value: str) -> Optional[str]:
230
+ try:
231
+ jev_backend._check_url(jev_backend._join_url(value, "/"))
232
+ except Exception as exc:
233
+ return str(exc)
234
+ return None
235
+
236
+ base = ui.ask("Base URL", jev["base_url"], validate=valid_url)
237
+ path = ui.ask("Endpoint path", jev["endpoint_path"])
238
+ model = ui.ask("Model", jev["model"])
239
+ save_config({"jev": {"base_url": base, "endpoint_path": path, "model": model}})
240
+ env_name = load_config()["jev"]["api_key_env"]
241
+ import os
242
+
243
+ if os.environ.get(env_name, "").strip():
244
+ ui.ok(f"using ${env_name} from your environment")
245
+ if self.confirm("Also store it privately so a daemon started at login can use it?", False):
246
+ path = save_secret(env_name, os.environ[env_name].strip())
247
+ ui.ok(f"stored in {path} (readable only by you)")
248
+ elif read_secret(env_name):
249
+ ui.ok(f"using the key stored in {secrets_path()}")
250
+ if not self.yes and ui.confirm("Replace it?", False):
251
+ self._ask_key(env_name)
252
+ elif self.yes:
253
+ self.next_steps.append(f"provide the jev API key: export {env_name}=… or run subcortex setup")
254
+ return
255
+ else:
256
+ self._ask_key(env_name)
257
+ if read_secret(env_name) and self.confirm("Check the key with one test decision?", True):
258
+ self.test_jev()
259
+
260
+ def _ask_key(self, env_name: str) -> None:
261
+ key = self.ui.ask(f"{env_name} (input hidden; stored with mode 600)", secret=True)
262
+ if key:
263
+ path = save_secret(env_name, key)
264
+ self.ui.ok(f"stored in {path} (readable only by you)")
265
+ else:
266
+ self.next_steps.append(f"provide the jev API key: export {env_name}=… or run subcortex setup")
267
+
268
+ def test_jev(self) -> None:
269
+ from .backends import get_backend
270
+
271
+ backend = get_backend(load_config(), "jev")
272
+ try:
273
+ with self.ui.spinner("asking jev"):
274
+ started = time.perf_counter()
275
+ result = backend.predict(
276
+ {"prompt": "what is 2+2?"},
277
+ {"arithmetic": {"type": "noul",
278
+ "instructions": "The request in `prompt` asks for an arithmetic result."}})
279
+ model = result.get("model") if isinstance(result.get("model"), str) else "jev"
280
+ self.ui.ok(f"key works — {model} answered in {(time.perf_counter() - started) * 1000:.0f} ms")
281
+ except Exception as exc:
282
+ self.ui.error(f"test decision failed: {exc}")
283
+ self.next_steps.append("check the jev key/endpoint: subcortex setup")
284
+
285
+ # -- 2. behaviors ------------------------------------------------------------------------
286
+
287
+ def step_features(self) -> None:
288
+ self.ui.step(2, STEPS, "Behaviors")
289
+ current = {k for k, v in (load_config().get("features") or {}).items() if v}
290
+ chosen = set(current if self.yes else self.ui.checklist("What should subcortex do?", FEATURES, current))
291
+ save_config({"features": {key: key in chosen for key, _, _ in FEATURES}})
292
+ if chosen:
293
+ self.ui.ok(", ".join(label.lower() for key, label, _ in FEATURES if key in chosen))
294
+ else:
295
+ self.ui.warn("all behaviors are off — hooks will pass everything through")
296
+
297
+ # -- 3. TUIs --------------------------------------------------------------------------------
298
+
299
+ def step_tuis(self) -> Tuple[List[str], List[str]]:
300
+ ui = self.ui
301
+ ui.step(3, STEPS, "Coding TUIs")
302
+ rows = tui_rows()
303
+ installed = {r["name"] for r in rows if r["installed"]}
304
+ requested = getattr(self.args, "tuis", None)
305
+ if requested:
306
+ selected = _resolve(requested, rows)
307
+ elif self.yes:
308
+ selected = [r["name"] for r in rows if r["detected"] or r["installed"]]
309
+ else:
310
+ ui.dim("Found on this machine are preselected; the others can be set up ahead of time.")
311
+ preselected = {r["name"] for r in rows if r["detected"] or r["installed"]}
312
+ selected = pick_tuis(ui, "Wire subcortex into which TUIs?", rows, preselected)
313
+ remove = sorted(installed - set(selected))
314
+ if remove and not self.yes:
315
+ names = ", ".join(installers.get_installer(n).display_name for n in remove)
316
+ if not ui.confirm(f"Remove subcortex from {names} (not selected)?", False):
317
+ remove = []
318
+ elif self.yes:
319
+ remove = []
320
+ names = [installers.get_installer(n).display_name for n in selected]
321
+ self.ui.ok(", ".join(names) if names else "no TUIs selected")
322
+ self.mcp = bool(getattr(self.args, "mcp", False))
323
+ if not self.mcp and not self.yes and any(r["mcp"] for r in rows if r["name"] in selected):
324
+ self.mcp = ui.confirm("Also register the on-demand MCP tools where supported? "
325
+ "(the model pays tokens each time it calls them)", False)
326
+ return selected, remove
327
+
328
+ # -- 4. review & install --------------------------------------------------------------------
329
+
330
+ def step_install(self, selected: List[str], remove: List[str]) -> None:
331
+ ui = self.ui
332
+ ui.step(4, STEPS, "Review & install")
333
+ if not selected and not remove:
334
+ ui.info("no TUIs selected")
335
+ return
336
+ work: List[Tuple[str, str, Any, List[Plan]]] = []
337
+ for name in selected:
338
+ installer = installers.get_installer(name, mcp=self.mcp)
339
+ try:
340
+ plans = installer.plans()
341
+ except InstallError as exc:
342
+ ui.error(f"{installer.display_name}: {exc}")
343
+ continue
344
+ work.append(("install", name, installer, plans))
345
+ for name in remove:
346
+ installer = installers.get_installer(name, mcp=True)
347
+ try:
348
+ work.append(("uninstall", name, installer, installer.plans(uninstall=True)))
349
+ except InstallError as exc:
350
+ ui.error(f"{installer.display_name}: {exc}")
351
+ pending = [w for w in work if any(p.changed for p in w[3])]
352
+ for action, name, installer, plans in work:
353
+ changed = [p for p in plans if p.changed]
354
+ verb = "remove" if action == "uninstall" else "set up"
355
+ if not changed:
356
+ ui.ok(f"{installer.display_name}: already {'removed' if action == 'uninstall' else 'set up'}")
357
+ continue
358
+ ui.info(f"{installer.display_name} — {verb}:")
359
+ for plan in changed:
360
+ ui.dim(f" {_diffstat(plan)}")
361
+ if not pending:
362
+ return
363
+ if not self.yes and ui.confirm("Show the full diffs?", False):
364
+ for _, _, _, plans in pending:
365
+ for plan in plans:
366
+ if plan.changed:
367
+ ui.write(plan.diff() or f"(creates {plan.path})")
368
+ if not self.confirm("Apply these changes?", True):
369
+ ui.warn("nothing written to any TUI config")
370
+ return
371
+ for action, name, installer, _ in pending:
372
+ label = installer.display_name
373
+ if action == "uninstall":
374
+ result = installer.uninstall()
375
+ else:
376
+ with ui.spinner(f"{label}: testing the hook commands, then writing"):
377
+ result = installer.install(check_version=not getattr(self.args, "ignore_version", False))
378
+ if result.ok:
379
+ ui.ok(f"{label}: {'removed' if action == 'uninstall' else 'installed'}")
380
+ for backup in result.backups:
381
+ ui.dim(f" backup: {backup}")
382
+ for message in result.messages:
383
+ if message == installer.post_install:
384
+ self.next_steps.append(f"{label}: {message}")
385
+ elif "not found on PATH" not in message:
386
+ ui.dim(f" {message}")
387
+ else:
388
+ ui.error(f"{label}: nothing written")
389
+ for message in result.messages:
390
+ ui.dim(f" {message}")
391
+
392
+ # -- 5. daemon ------------------------------------------------------------------------------
393
+
394
+ def restart_daemon(self) -> bool:
395
+ from . import cli
396
+
397
+ cfg = load_config()
398
+ health = cli._health(cfg)
399
+ wanted = (cfg.get("backend"), __version__)
400
+ if health and (health.get("backend"), health.get("version")) == wanted:
401
+ return True
402
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
403
+ if health:
404
+ cli._stop_daemon()
405
+ time.sleep(0.5)
406
+ cli._spawn_daemon(cfg)
407
+ return cli._wait_for_health(cfg) is not None
408
+
409
+ def step_daemon(self) -> None:
410
+ ui = self.ui
411
+ ui.step(5, STEPS, "Daemon")
412
+ if self.confirm("Start (or restart) the daemon now?", True):
413
+ with ui.spinner("starting the daemon"):
414
+ ok = self.restart_daemon()
415
+ (ui.ok if ok else ui.error)("daemon running" if ok else "daemon did not start; see `subcortex doctor`")
416
+ st = service.status()
417
+ if not st["supported"]:
418
+ ui.dim("no login service on this platform; hooks start the daemon when they need it")
419
+ return
420
+ if st["installed"]:
421
+ ui.ok(f"starts at login ({st['path']})")
422
+ return
423
+ wanted = getattr(self.args, "service", None)
424
+ if wanted is None:
425
+ wanted = self.confirm("Start the daemon automatically at login?", False)
426
+ if wanted:
427
+ try:
428
+ for note in service.install():
429
+ ui.ok(note)
430
+ except RuntimeError as exc:
431
+ ui.error(str(exc))
432
+ else:
433
+ ui.dim("without it, the first hook after a reboot starts the daemon (that request passes through)")
434
+
435
+ # -- summary ------------------------------------------------------------------------------
436
+
437
+ def summary(self) -> None:
438
+ ui = self.ui
439
+ ui.title("Done")
440
+ for step in self.next_steps:
441
+ ui.info(f"→ {step}")
442
+ ui.dim("Check health any time: subcortex doctor · change choices: subcortex setup")
443
+
444
+
445
+ def _resolve(requested: Sequence[str], rows: Sequence[Dict[str, Any]]) -> List[str]:
446
+ names: List[str] = []
447
+ for token in requested:
448
+ for part in str(token).split(","):
449
+ part = part.strip()
450
+ if part == "detected":
451
+ names += [r["name"] for r in rows if r["detected"]]
452
+ elif part == "all":
453
+ names += [r["name"] for r in rows]
454
+ elif part == "none":
455
+ continue
456
+ elif part:
457
+ key = installers.canonical_name(part)
458
+ if key is None:
459
+ raise SystemExit(f"unknown TUI {part!r}; see: subcortex tuis")
460
+ names.append(key)
461
+ return list(dict.fromkeys(names))
462
+
463
+
464
+ def _post(port: int, path: str, payload: Dict[str, Any], timeout: float) -> Dict[str, Any]:
465
+ from . import localhttp
466
+
467
+ _, reply = localhttp.request(port, "POST", path, payload, timeout)
468
+ if not isinstance(reply, dict):
469
+ raise ValueError("daemon reply is not a JSON object")
470
+ return reply
471
+
472
+
473
+ def run(args: argparse.Namespace, ui: Optional[UI] = None) -> int:
474
+ return Wizard(ui or UI(), args).run()