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/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()
|