agent-bios 0.18.0 → 0.19.1

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 (55) hide show
  1. package/DEPENDENCIES.md +236 -80
  2. package/INSTALL.md +112 -0
  3. package/README.md +187 -524
  4. package/claude/CLAUDE.md +1 -1
  5. package/claude/guides/cli-multi-model-workflow.md +1 -1
  6. package/claude/guides/learning-flow.md +23 -12
  7. package/claude/guides/session-distill-workflow.md +22 -12
  8. package/claude/skills/understand/SKILL.md +52 -22
  9. package/codex/AGENTS.md +1 -1
  10. package/codex/guides/cli-multi-model-workflow.md +1 -1
  11. package/codex/guides/learning-flow.md +23 -12
  12. package/codex/guides/session-distill-workflow.md +22 -12
  13. package/compose/app_bridge/SKILL.md +75 -0
  14. package/compose/app_bridge/agents/openai.yaml +2 -0
  15. package/compose/app_bridge/scripts/bridge.py +76 -0
  16. package/compose/bootstrap/SKILL.md +12 -1
  17. package/compose/corpus.py +31 -9
  18. package/compose/corpus_app.py +456 -0
  19. package/compose/corpus_import.py +529 -0
  20. package/compose/corpus_install.py +202 -18
  21. package/compose/corpus_session.py +27 -0
  22. package/compose/corpus_setup.py +676 -0
  23. package/compose/corpus_setup_cli.py +585 -0
  24. package/compose/corpus_setup_i18n.py +324 -0
  25. package/compose/corpus_setup_ui.py +647 -0
  26. package/compose/corpus_store.py +213 -32
  27. package/compose/corpus_transaction.py +43 -10
  28. package/compose/corpus_ui_runtime.py +278 -0
  29. package/compose/corpus_understand.py +173 -22
  30. package/compose/setup/START.md +158 -0
  31. package/compose/ui_runtime/linkify_it_py-2.2.0-py3-none-any.whl +0 -0
  32. package/compose/ui_runtime/manifest.json +238 -0
  33. package/compose/ui_runtime/markdown_it_py-4.2.0-py3-none-any.whl +0 -0
  34. package/compose/ui_runtime/mdit_py_plugins-0.6.1-py3-none-any.whl +0 -0
  35. package/compose/ui_runtime/mdurl-0.1.2-py3-none-any.whl +0 -0
  36. package/compose/ui_runtime/platformdirs-4.11.8-py3-none-any.whl +0 -0
  37. package/compose/ui_runtime/pygments-2.21.0-py3-none-any.whl +0 -0
  38. package/compose/ui_runtime/rich-15.0.0-py3-none-any.whl +0 -0
  39. package/compose/ui_runtime/textual-8.2.8-py3-none-any.whl +0 -0
  40. package/compose/ui_runtime/typing_extensions-4.16.0-py3-none-any.whl +0 -0
  41. package/docs/advanced-launch.md +131 -0
  42. package/docs/assets/corpus-studio.svg +227 -0
  43. package/docs/corpus.md +117 -0
  44. package/docs/recovery.md +201 -0
  45. package/docs/session-model.md +120 -0
  46. package/docs/setup.md +206 -0
  47. package/docs/understand.md +88 -0
  48. package/install.sh +75 -46
  49. package/launch/agent-launch.py +99 -52
  50. package/launch/provision-venv.sh +44 -13
  51. package/learn/collect-learning.py +14 -5
  52. package/learn/learning.schema.json +2 -2
  53. package/package.json +14 -2
  54. package/provenance.json +1 -1
  55. package/wrappers/claude-run.sh +10 -13
@@ -0,0 +1,647 @@
1
+ """Textual installation client over the shared setup controller."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import json
6
+ import re
7
+ import threading
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rich.text import Text
12
+ from textual import work
13
+ from textual.app import App, ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Horizontal, Vertical, VerticalScroll
16
+ from textual.widgets import (
17
+ Button, Checkbox, Collapsible, Header, Input, Label,
18
+ LoadingIndicator, Select, SelectionList, Static, TextArea,
19
+ )
20
+ from textual.widgets.selection_list import Selection
21
+
22
+ try:
23
+ from corpus_setup import SetupError, format_setup_result, review_summary
24
+ import corpus_setup_i18n as i18n
25
+ except ImportError:
26
+ from .corpus_setup import SetupError, format_setup_result, review_summary
27
+ from . import corpus_setup_i18n as i18n
28
+
29
+
30
+ STAGES = ("Choose corpus", "Prepare personal instructions", "Choose dependencies", "Review setup")
31
+ LANGUAGE_TITLE = "Language / 언어 / 言語"
32
+ LANGUAGE_CONTINUE = "Continue / 계속 / 続ける"
33
+ LANGUAGE_CANCEL = "Cancel / 취소 / 中止"
34
+
35
+
36
+ def visible(value: Any) -> str:
37
+ return re.sub(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]", " ", str(value))
38
+
39
+
40
+ class SetupApp(App[dict[str, Any]]):
41
+ """Four guided screens; only the controller's Apply performs setup effects."""
42
+
43
+ TITLE = "agent-bios setup"
44
+ ENABLE_COMMAND_PALETTE = False
45
+ BINDINGS = [
46
+ Binding("escape", "cancel", "Cancel", show=False),
47
+ Binding("ctrl+c", "cancel", "Cancel", show=False, priority=True),
48
+ Binding("ctrl+q", "cancel", "Cancel", show=False, priority=True),
49
+ ]
50
+ CSS = """
51
+ Screen { background: $background; }
52
+ #frame { padding: 0 1; height: 1fr; }
53
+ #step-title { height: 2; text-style: bold; color: $accent; }
54
+ #language-select { margin-bottom: 1; }
55
+ #language-continue { width: auto; min-width: 28; dock: right; }
56
+ #key-help { height: auto; max-height: 2; color: $text-muted; padding: 0 1; }
57
+ #body { height: 1fr; }
58
+ .stage { height: auto; }
59
+ Label { width: 100%; height: auto; margin-bottom: 1; }
60
+ #corpus-mode { margin-bottom: 1; }
61
+ #corpus-choices { height: 9; margin-bottom: 1; }
62
+ #corpus-help, #source-help, #dependency-help, #retained-corpus-help, #app-bridge-help { color: $text-muted; }
63
+ #retained-corpus-choices { height: auto; max-height: 6; margin-bottom: 1; }
64
+ #app-bridge-help { height: auto; margin: 1 0; }
65
+ #project-actions { height: 3; }
66
+ #project-path { width: 1fr; }
67
+ #add-project, #clear-projects { min-width: 12; width: auto; }
68
+ #project-list, #source-notes { height: auto; margin: 1 0; }
69
+ #source-choices { height: 10; }
70
+ #dependency-choices { height: 12; }
71
+ #dependency-detail { height: auto; margin-top: 1; }
72
+ #source-selection, #dependency-selection, #source-detail, #inventory-reference { width: 100%; height: auto; }
73
+ #summary { height: auto; padding: 0 1; }
74
+ #exact-json, #operation-log { height: 12; }
75
+ Collapsible { height: auto; margin-top: 1; }
76
+ #status { width: 100%; height: auto; max-height: 4; margin-top: 1; }
77
+ #working { height: 1; }
78
+ #actions { height: 3; margin-top: 1; }
79
+ #actions Button { min-width: 12; margin-right: 1; }
80
+ #next, #apply, #done { dock: right; }
81
+ """
82
+
83
+ def __init__(self, installer: Any, *, dry_run: bool = False,
84
+ initial_plan: dict[str, Any] | None = None, controller: Any = None):
85
+ super().__init__()
86
+ self.installer = installer
87
+ self.language = i18n.detect_language(getattr(installer, "env", {}))
88
+ self.dry_run = dry_run
89
+ self.initial_plan = copy.deepcopy(initial_plan)
90
+ self.controller = controller
91
+ self.plan: dict[str, Any] = {}
92
+ self.preview_result: dict[str, Any] | None = None
93
+ self.result: dict[str, Any] | None = None
94
+ self.backend_error: Exception | None = None
95
+ self.step = -1
96
+ self.ready = False
97
+ self.busy = False
98
+ self.applying = False
99
+ self.started_effects = False
100
+ self.closing = False
101
+ self.cancel_requested = threading.Event()
102
+ self.discovery_serial = 0
103
+ self.sources: list[dict[str, Any]] = []
104
+ self.source_omitted: list[dict[str, Any]] = []
105
+ self.sources_loaded = False
106
+ self.operation_output: list[str] = []
107
+ self.status_message = "Choose your language to continue."
108
+ self.status_values: dict[str, Any] = {}
109
+
110
+ def _t(self, message: str, **values) -> str:
111
+ return i18n.translate(self.language, message, **values)
112
+
113
+ def compose(self) -> ComposeResult:
114
+ yield Header(show_clock=False)
115
+ with Vertical(id="frame"):
116
+ yield Static(self._t(LANGUAGE_TITLE), id="step-title")
117
+ with VerticalScroll(id="body"):
118
+ with Vertical(id="language-panel", classes="stage"):
119
+ yield Label(self._t("Choose your language to continue."), id="language-help")
120
+ yield Select(i18n.LANGUAGES, value=self.language, allow_blank=False, id="language-select")
121
+ with Vertical(id="corpus-panel", classes="stage"):
122
+ yield Label(self._t("Choose what future activated launches may use. App tasks require their own explicit use."), id="corpus-help")
123
+ yield Select([
124
+ (self._t("No active corpus"), "none"),
125
+ (self._t("All available corpus"), "all"),
126
+ (self._t("Choose supplied packages or domains"), "selected"),
127
+ (self._t("Keep saved/default selection"), "keep"),
128
+ ], value="none", allow_blank=False, id="corpus-mode")
129
+ yield SelectionList(id="corpus-choices")
130
+ with Vertical(id="retained-corpus-panel"):
131
+ yield Label(self._t("Already on this device — kept unchanged. This does not turn on corpus use."), id="retained-corpus-help")
132
+ yield SelectionList(id="retained-corpus-choices", disabled=True)
133
+ yield Checkbox(self._t("Connect to the Codex app"), id="app-bridge")
134
+ yield Static(self._t("Adds the $agent-bios command to Codex app conversations for setup and personal instruction management. Choose separately which tasks use the instructions."), id="app-bridge-help")
135
+ with Vertical(id="sources-panel", classes="stage"):
136
+ yield Label(self._t("Optional: capture existing instructions for later model review. Space toggles a source; originals stay unchanged."), id="source-help")
137
+ with Horizontal(id="project-actions"):
138
+ yield Input(placeholder=self._t("Project folder path (optional)"), id="project-path")
139
+ yield Button(self._t("Add folder"), id="add-project")
140
+ yield Button(self._t("Clear folders"), id="clear-projects")
141
+ yield Static(self._t("Global instruction files only"), id="project-list")
142
+ yield SelectionList(id="source-choices")
143
+ yield Static(self._t("No sources selected"), id="source-selection")
144
+ yield Static("", id="source-detail")
145
+ yield Static("", id="source-notes")
146
+ with Vertical(id="dependencies-panel", classes="stage"):
147
+ yield Label(self._t("Available dependencies are checked and locked. Choose only additional installations; missing items without an installer stay unchecked."), id="dependency-help")
148
+ yield SelectionList(id="dependency-choices")
149
+ yield Static(self._t("No dependency installation selected"), id="dependency-selection")
150
+ yield Static("", id="dependency-detail")
151
+ with Collapsible(title=self._t("Every dependency: purpose and location"), id="inventory-details"):
152
+ yield Static("", id="inventory-reference")
153
+ with Vertical(id="review-panel", classes="stage"):
154
+ yield Static("", id="summary")
155
+ with Collapsible(title=self._t("Exact commands, paths and plan"), id="details"):
156
+ yield TextArea(read_only=True, soft_wrap=True, show_line_numbers=False, id="exact-json")
157
+ with Collapsible(title=self._t("Operation output"), id="logs"):
158
+ yield TextArea(read_only=True, soft_wrap=True, show_line_numbers=False, id="operation-log")
159
+ yield Static(self._t("Choose your language to continue."), id="status")
160
+ yield LoadingIndicator(id="working")
161
+ with Horizontal(id="actions"):
162
+ yield Button(self._t(LANGUAGE_CONTINUE), id="language-continue", variant="primary")
163
+ yield Button(self._t("Back"), id="back")
164
+ yield Button(self._t("Cancel"), id="cancel")
165
+ yield Button(self._t("Next"), id="next", variant="primary")
166
+ yield Button(self._t("Apply setup"), id="apply", variant="success")
167
+ yield Button(self._t("Close"), id="done", variant="primary")
168
+ yield Static(self._t("Esc / Ctrl+C: Cancel Tab: Move Space: Toggle"), id="key-help")
169
+
170
+ def on_mount(self) -> None:
171
+ self._localize()
172
+ self._show_step()
173
+
174
+ def _from_worker(self, callback, *args) -> None:
175
+ if not self.closing:
176
+ try:
177
+ self.call_from_thread(callback, *args)
178
+ except RuntimeError:
179
+ if not self.closing:
180
+ raise
181
+
182
+ @work(thread=True, exit_on_error=False)
183
+ def _initialize(self) -> None:
184
+ try:
185
+ if self.controller is None:
186
+ try:
187
+ from corpus_setup import SetupController
188
+ except ImportError:
189
+ from .corpus_setup import SetupController
190
+ controller = SetupController(self.installer)
191
+ else:
192
+ controller = self.controller
193
+ plan = controller.default_plan()
194
+ if self.initial_plan is not None:
195
+ plan.update(copy.deepcopy(self.initial_plan))
196
+ self._from_worker(self._initialized, controller, plan)
197
+ except Exception as exc:
198
+ self._from_worker(self._initialization_failed, exc)
199
+
200
+ def _initialized(self, controller, plan) -> None:
201
+ self.controller = controller
202
+ self.plan = copy.deepcopy(plan)
203
+ self.ready = True
204
+ self.busy = False
205
+ self.step = 0
206
+ self._localize()
207
+ self._set_status("No installation changes yet. Choose only what you want to use.")
208
+ self._show_step()
209
+
210
+ def _render_options(self) -> None:
211
+ choices = self.query_one("#corpus-choices", SelectionList)
212
+ selected = self.plan.get("targets") or []
213
+ known = {row["target"] for row in self.controller.choices}
214
+ choices.clear_options()
215
+ choices.add_options([Selection(Text(visible(i18n.choice_label(self.language, row))), row["target"], row["target"] in selected)
216
+ for row in self.controller.choices])
217
+ choices.add_options([Selection(Text(self._t("All available corpus") if value == "all" else self._t("Selected item: {item}", item=visible(value))), value, True)
218
+ for value in selected if value not in known])
219
+ mode = self.plan.get("selection_mode")
220
+ mode_value = "keep" if mode is None else "all" if selected == ["all"] else "selected" if mode == "selected" else "none"
221
+ self.query_one("#corpus-mode", Select).value = mode_value
222
+ self.query_one("#app-bridge", Checkbox).value = bool(self.plan.get("app_bridge"))
223
+ retained = getattr(self.controller, "retained_corpus", [])
224
+ local = self.query_one("#retained-corpus-choices", SelectionList)
225
+ local.clear_options()
226
+ local.add_options([
227
+ Selection(Text(visible(self._t("{name} — {count} items retained", name=self._t(row["label"]), count=row["item_count"]))),
228
+ row["target"], True, disabled=True)
229
+ for row in retained
230
+ ])
231
+ self.query_one("#retained-corpus-panel").display = bool(retained)
232
+ dependencies = self.query_one("#dependency-choices", SelectionList)
233
+ requested = set(self.plan.get("dependencies") or [])
234
+ display = [i18n.dependency_display(self.language, row) for row in self.controller.dependencies]
235
+ dependencies.clear_options()
236
+ dependencies.add_options([
237
+ Selection(Text(visible(f"{row['title']} — {row['status']} {row.get('version', '')}")), row["id"],
238
+ raw.get("status") == "available" or (row["id"] in requested and bool(row.get("install_argv"))),
239
+ disabled=raw.get("status") == "available" or not bool(row.get("install_argv")))
240
+ for raw, row in zip(self.controller.dependencies, display)
241
+ ])
242
+ self.query_one("#inventory-reference", Static).update(Text(visible("\n\n".join(
243
+ f"{row['title']} — {row['status']} {row.get('version', '')}\n{row['purpose']}"
244
+ + ("\n" + self._t("Location: {path}", path=row["install_scope"]) if row.get("install_scope") else "")
245
+ + ("\n" + row["manual_reason"] if row.get("manual_reason") else "")
246
+ for row in display))))
247
+ if self.sources_loaded:
248
+ self._render_sources(self.plan.get("project_roots") or [], self.plan.get("import_paths") or [])
249
+
250
+ def _localize(self) -> None:
251
+ self.title = self._t("agent-bios setup")
252
+ messages = {
253
+ "language-help": "Choose your language to continue.",
254
+ "corpus-help": "Choose what future activated launches may use. App tasks require their own explicit use.",
255
+ "retained-corpus-help": "Already on this device — kept unchanged. This does not turn on corpus use.",
256
+ "app-bridge-help": "Adds the $agent-bios command to Codex app conversations for setup and personal instruction management. Choose separately which tasks use the instructions.",
257
+ "source-help": "Optional: capture existing instructions for later model review. Space toggles a source; originals stay unchanged.",
258
+ "dependency-help": "Available dependencies are checked and locked. Choose only additional installations; missing items without an installer stay unchecked.",
259
+ "key-help": "Esc / Ctrl+C: Cancel Tab: Move Space: Toggle",
260
+ }
261
+ for identifier, message in messages.items():
262
+ self.query_one("#" + identifier, Static).update(Text(self._t(message)))
263
+ mode = self.query_one("#corpus-mode", Select)
264
+ old_value = mode.value
265
+ mode.set_options([(self._t("No active corpus"), "none"), (self._t("All available corpus"), "all"),
266
+ (self._t("Choose supplied packages or domains"), "selected"), (self._t("Keep saved/default selection"), "keep")])
267
+ mode.value = old_value
268
+ mode.mutate_reactive(Select.value)
269
+ self.query_one("#project-path", Input).placeholder = self._t("Project folder path (optional)")
270
+ for identifier, message in (("add-project", "Add folder"), ("clear-projects", "Clear folders"),
271
+ ("back", "Back"), ("next", "Next"), ("apply", "Apply setup")):
272
+ self.query_one("#" + identifier, Button).label = self._t(message)
273
+ for identifier, message in (("details", "Exact commands, paths and plan"), ("logs", "Operation output"),
274
+ ("inventory-details", "Every dependency: purpose and location")):
275
+ self.query_one("#" + identifier, Collapsible).title = self._t(message)
276
+ self.query_one("#app-bridge", Checkbox).label = self._t("Connect to the Codex app")
277
+ for identifier, message in (("source-selection", "No sources selected"),
278
+ ("dependency-selection", "No dependency installation selected"),
279
+ ("project-list", "Global instruction files only")):
280
+ self.query_one("#" + identifier, Static).update(Text(self._t(message)))
281
+ if self.ready:
282
+ self._render_options()
283
+ self._set_status(self.status_message, **self.status_values)
284
+
285
+ def _initialization_failed(self, exc: Exception) -> None:
286
+ self.backend_error = exc
287
+ self.busy = False
288
+ self.result = {"applied": False, "setup_error": str(exc)}
289
+ self.query_one("#summary", Static).update(Text(self._t("Setup could not be prepared.\n\n{error}", error=visible(exc))))
290
+ self.step = 3
291
+ self._show_step()
292
+ self._set_status("No setup plan was applied. Close to see the diagnostic.")
293
+
294
+ def _set_status(self, message: str, **values) -> None:
295
+ self.status_message = message
296
+ self.status_values = values
297
+ self.query_one("#status", Static).update(Text(visible(self._t(message, **values))))
298
+
299
+ def _append_output(self, output: str) -> None:
300
+ if output:
301
+ self.operation_output.append(visible(output))
302
+ self.query_one("#operation-log", TextArea).load_text("\n".join(self.operation_output)[-60000:])
303
+
304
+ def _show_step(self, *, focus: bool = True) -> None:
305
+ language_stage = self.step == -1
306
+ self.query_one("#language-panel").display = language_stage
307
+ self.query_one("#language-panel").disabled = self.busy
308
+ panels = ("corpus-panel", "sources-panel", "dependencies-panel", "review-panel")
309
+ for index, identifier in enumerate(panels):
310
+ panel = self.query_one("#" + identifier)
311
+ panel.display = index == self.step
312
+ panel.disabled = self.busy and index != 3
313
+ title = self._t(LANGUAGE_TITLE) if language_stage else self._t("Setup result") if self.result is not None else self._t("{step} of 4 — {stage}", step=self.step + 1, stage=self._t(STAGES[self.step]))
314
+ self.query_one("#step-title", Static).update(Text(title))
315
+ self.query_one("#working").display = self.busy
316
+ self.query_one("#language-continue", Button).display = language_stage
317
+ self.query_one("#language-continue", Button).disabled = self.busy
318
+ self.query_one("#back", Button).display = not language_stage
319
+ self.query_one("#back", Button).disabled = self.busy or self.result is not None
320
+ self.query_one("#next", Button).display = 0 <= self.step < 3 and self.result is None
321
+ self.query_one("#next", Button).disabled = self.busy or not self.ready
322
+ self.query_one("#apply", Button).display = self.step == 3 and self.result is None and not self.dry_run
323
+ self.query_one("#apply", Button).disabled = self.busy or self.preview_result is None
324
+ self.query_one("#done", Button).display = self.result is not None or (self.step == 3 and self.dry_run)
325
+ self.query_one("#done", Button).disabled = self.busy
326
+ self.query_one("#done", Button).label = self._t("Close preview") if self.dry_run and self.result is None else self._t("Close")
327
+ self.query_one("#cancel", Button).display = self.result is None
328
+ self.query_one("#cancel", Button).label = self._t(LANGUAGE_CANCEL) if language_stage else self._t("Stop request") if self.applying else self._t("Cancel")
329
+ self.query_one("#corpus-choices").display = self.query_one("#corpus-mode", Select).value == "selected"
330
+ self.query_one("#logs").display = bool(self.operation_output) or self.applying
331
+ self.query_one("#body", VerticalScroll).scroll_home(animate=False)
332
+ if focus and not self.busy:
333
+ target = "#language-select" if language_stage else "#done" if self.result is not None or (self.dry_run and self.step == 3) else (
334
+ "#corpus-mode", "#project-path", "#dependency-choices", "#back")[self.step]
335
+ self.query_one(target).focus()
336
+
337
+ def on_select_changed(self, event: Select.Changed) -> None:
338
+ if event.select.id == "language-select" and event.value in {code for _label, code in i18n.LANGUAGES}:
339
+ self.language = event.value
340
+ self._localize()
341
+ self._show_step(focus=False)
342
+ elif event.select.id == "corpus-mode":
343
+ self.query_one("#corpus-choices").display = event.value == "selected"
344
+
345
+ def _requested_dependencies(self) -> list[str]:
346
+ selectable = {row["id"] for row in self.controller.dependencies
347
+ if row.get("install_argv") and row.get("status") != "available"}
348
+ return [value for value in self.query_one("#dependency-choices", SelectionList).selected
349
+ if value in selectable]
350
+
351
+ def on_selection_list_selected_changed(self, event: SelectionList.SelectedChanged) -> None:
352
+ if self.controller is None:
353
+ return
354
+ widget = event.selection_list
355
+ if widget.id == "corpus-choices":
356
+ return
357
+ elif widget.id == "source-choices":
358
+ message = self._t("Selected files:\n{paths}", paths="\n".join(widget.selected)) if widget.selected else self._t("No sources selected")
359
+ identifier = "#source-selection"
360
+ elif widget.id == "dependency-choices":
361
+ labels = {row["id"]: i18n.dependency_display(self.language, row)["title"] for row in self.controller.dependencies}
362
+ requested = self._requested_dependencies()
363
+ message = self._t("Install: {dependencies}", dependencies=", ".join(labels.get(value, value) for value in requested)) if requested else self._t("No dependency installation selected")
364
+ identifier = "#dependency-selection"
365
+ else:
366
+ return
367
+ self.query_one(identifier, Static).update(Text(visible(message)))
368
+
369
+ def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted) -> None:
370
+ if event.selection_list.id == "dependency-choices" and self.controller is not None:
371
+ row = next((entry for entry in self.controller.dependencies if entry["id"] == event.selection.value), None)
372
+ if row:
373
+ row = i18n.dependency_display(self.language, row)
374
+ lines = [row["purpose"]]
375
+ if row.get("version"):
376
+ lines.append(self._t("Detected: {version}", version=row["version"]))
377
+ if row.get("install_scope"):
378
+ lines.append(self._t("Installation location: {path}", path=row["install_scope"]))
379
+ if row.get("manual_reason"):
380
+ lines.append(row["manual_reason"])
381
+ self.query_one("#dependency-detail", Static).update(Text(visible("\n".join(lines))))
382
+ elif event.selection_list.id == "source-choices":
383
+ self.query_one("#source-detail", Static).update(Text(visible(event.selection.value)))
384
+
385
+ def _collect(self, *, validate: bool = True) -> None:
386
+ if self.step == 0:
387
+ mode = self.query_one("#corpus-mode", Select).value
388
+ chosen = list(self.query_one("#corpus-choices", SelectionList).selected)
389
+ if validate and mode == "selected" and not chosen:
390
+ raise ValueError("Choose at least one corpus, or select No active corpus.")
391
+ self.plan["selection_mode"] = None if mode == "keep" else "none" if mode == "none" else "selected"
392
+ self.plan["targets"] = None if mode == "keep" else ["all"] if mode == "all" else chosen if mode == "selected" else []
393
+ self.plan["app_bridge"] = self.query_one("#app-bridge", Checkbox).value
394
+ elif self.step == 1:
395
+ self.plan["import_paths"] = list(self.query_one("#source-choices", SelectionList).selected)
396
+ elif self.step == 2:
397
+ self.plan["dependencies"] = self._requested_dependencies()
398
+
399
+ def _discover(self) -> None:
400
+ self.discovery_serial += 1
401
+ self.busy = True
402
+ self._show_step(focus=False)
403
+ self._set_status("Finding instruction files in the selected locations…")
404
+ self._discover_worker(self.discovery_serial, list(self.plan.get("project_roots") or []),
405
+ list(self.plan.get("import_paths") or []))
406
+
407
+ @work(thread=True, group="discovery", exclusive=True, exit_on_error=False)
408
+ def _discover_worker(self, serial: int, roots: list[str], selected: list[str]) -> None:
409
+ try:
410
+ result = self.controller.discover(roots)
411
+ self._from_worker(self._discovered, serial, roots, selected, result)
412
+ except Exception as exc:
413
+ self._from_worker(self._discovery_failed, serial, exc)
414
+
415
+ def _discovered(self, serial, roots, selected, result) -> None:
416
+ if serial != self.discovery_serial:
417
+ return
418
+ self.sources = result.get("sources", [])
419
+ self.source_omitted = result.get("omitted", [])
420
+ self.sources_loaded = True
421
+ self._render_sources(roots, selected)
422
+ self.busy = False
423
+ self._set_status("Capture is independent of corpus selection and needs later model review.")
424
+ self._show_step()
425
+
426
+ def _render_sources(self, roots, selected) -> None:
427
+ widget = self.query_one("#source-choices", SelectionList)
428
+ widget.clear_options()
429
+ widget.add_options([Selection(Text(visible(f"{Path(row['path']).name} — {self._t(row.get('scope', {}).get('kind', 'source'))}: {Path(row['path']).parent}")),
430
+ row["path"], row["path"] in selected) for row in self.sources])
431
+ self.query_one("#project-list", Static).update(Text(self._t("Project folders:\n{paths}", paths="\n".join(visible(value) for value in roots))
432
+ if roots else self._t("Global instruction files only; add a project folder to include its files.")))
433
+ notes = [self._t("{count} eligible file(s); selecting none is valid.", count=len(self.sources))]
434
+ notes.extend(self._t("Skipped {path}: {reason}", path=row.get("path", self._t("source")),
435
+ reason=row.get("reason", self._t("unavailable"))) for row in self.source_omitted)
436
+ self.query_one("#source-notes", Static).update(Text(visible("\n".join(notes))))
437
+
438
+ def _discovery_failed(self, serial, exc) -> None:
439
+ if serial != self.discovery_serial:
440
+ return
441
+ self.query_one("#source-choices", SelectionList).clear_options()
442
+ self.busy = False
443
+ self._set_status("Could not inspect those locations: {error}", error=str(exc))
444
+ self._show_step()
445
+
446
+ def _add_project(self) -> None:
447
+ value = self.query_one("#project-path", Input).value.strip()
448
+ if not value:
449
+ self._set_status("Enter a project folder path, or continue without adding one.")
450
+ return
451
+ path = Path(value).expanduser()
452
+ if not path.is_absolute() or not path.is_dir():
453
+ self._set_status("Choose an existing absolute project folder path.")
454
+ return
455
+ roots = list(self.plan.get("project_roots") or [])
456
+ normalized = str(path.resolve())
457
+ if normalized not in roots:
458
+ roots.append(normalized)
459
+ self.plan["project_roots"] = roots
460
+ self.plan["import_paths"] = list(self.query_one("#source-choices", SelectionList).selected)
461
+ self.query_one("#project-path", Input).value = ""
462
+ self._discover()
463
+
464
+ def on_input_submitted(self, event: Input.Submitted) -> None:
465
+ if event.input.id == "project-path" and not self.busy:
466
+ self._add_project()
467
+
468
+ @work(thread=True, group="preview", exclusive=True, exit_on_error=False)
469
+ def _preview_worker(self, plan: dict[str, Any]) -> None:
470
+ try:
471
+ preview = self.controller.preview(plan)
472
+ self._from_worker(self._previewed, preview)
473
+ except Exception as exc:
474
+ self._from_worker(self._preview_failed, exc)
475
+
476
+ def _previewed(self, preview) -> None:
477
+ self.preview_result = preview
478
+ self.step = 3
479
+ self.busy = False
480
+ self.query_one("#summary", Static).update(Text(review_summary(self.plan, self.controller.dependencies, self.controller.choices, self.language)))
481
+ self.query_one("#exact-json", TextArea).load_text(json.dumps(preview, ensure_ascii=False, indent=2, sort_keys=True))
482
+ self._set_status("Read-only preview. No Apply is available in dry-run mode." if self.dry_run else "Review the effects, then choose Apply setup.")
483
+ self._show_step()
484
+
485
+ def _preview_failed(self, exc) -> None:
486
+ self.busy = False
487
+ self.preview_result = None
488
+ self._set_status("Preview refused: {error}", error=str(exc))
489
+ self._show_step()
490
+
491
+ @work(thread=True, group="apply", exclusive=True, exit_on_error=False)
492
+ def _apply_worker(self, plan, preview) -> None:
493
+ try:
494
+ result = self.controller.apply(plan, preview=preview, progress=self._progress,
495
+ should_cancel=self.cancel_requested.is_set)
496
+ self._from_worker(self._applied, result)
497
+ except SetupError as exc:
498
+ self._from_worker(self._apply_failed if self.started_effects else self._apply_refused, exc)
499
+ except Exception as exc:
500
+ self._from_worker(self._apply_failed, exc)
501
+
502
+ def _progress(self, event: dict[str, Any]) -> None:
503
+ self._from_worker(self._progressed, event)
504
+
505
+ def _progressed(self, event: dict[str, Any]) -> None:
506
+ if event.get("stage") in {"dependency", "install", "extras"}:
507
+ self.started_effects = True
508
+ message = event.get("message")
509
+ stage = event.get("stage")
510
+ if stage == "dependency":
511
+ identifier = event.get("dependency", event.get("id", ""))
512
+ row = next((row for row in self.controller.dependencies if row["id"] == identifier), None)
513
+ name = i18n.dependency_display(self.language, row)["title"] if row else identifier
514
+ if "returncode" not in event:
515
+ self._set_status("Installing {dependency}…", dependency=name)
516
+ elif event["returncode"] is None:
517
+ self._set_status("Could not start {dependency}", dependency=name)
518
+ else:
519
+ self._set_status("Finished {dependency}", dependency=name)
520
+ elif stage == "install":
521
+ self._set_status("Installing the private runtime")
522
+ elif stage == "extras":
523
+ self._set_status("Preparing app registration and selected instruction capture")
524
+ elif stage == "complete":
525
+ self._set_status("Setup complete")
526
+ elif message:
527
+ self._set_status(str(message))
528
+ self._append_output(str(event.get("stdout") or "") + str(event.get("stderr") or ""))
529
+
530
+ def _apply_refused(self, exc) -> None:
531
+ self.applying = False
532
+ self.busy = False
533
+ self.preview_result = None
534
+ self.step = 2
535
+ self._set_status("Apply refused before changes: {error} Choose Next to review a fresh plan.", error=str(exc))
536
+ self._show_step()
537
+
538
+ def _applied(self, result) -> None:
539
+ self.result = result
540
+ self.busy = False
541
+ self.applying = False
542
+ self.query_one("#summary", Static).update(Text(visible(format_setup_result(result, language=self.language))))
543
+ self.query_one("#exact-json", TextArea).load_text(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
544
+ self._set_status("Stopped at a safe boundary; the outcome lists any retained changes." if result.get("cancelled")
545
+ else "The accepted operation finished. Its completed effects remain." if self.cancel_requested.is_set()
546
+ else "Review the outcome before closing.")
547
+ self._show_step()
548
+
549
+ def _apply_failed(self, exc) -> None:
550
+ self.backend_error = exc
551
+ self.result = {"applied": False, "execution_error": str(exc), "unknown_outcome": True}
552
+ self.busy = False
553
+ self.applying = False
554
+ self.query_one("#summary", Static).update(Text(self._t("Setup stopped with an error.\n\n{error}\n\nEarlier completed effects may remain. Inspect status before retrying.", error=visible(exc))))
555
+ self.query_one("#exact-json", TextArea).load_text(json.dumps(self.result, indent=2))
556
+ self._set_status("No rollback is claimed. Close to return the diagnostic.")
557
+ self._show_step()
558
+
559
+ def on_button_pressed(self, event: Button.Pressed) -> None:
560
+ button = event.button.id
561
+ if button == "cancel":
562
+ self.action_cancel()
563
+ return
564
+ if self.busy:
565
+ return
566
+ if button == "language-continue":
567
+ if self.ready:
568
+ self.step = 0
569
+ self._localize()
570
+ self._set_status("Your choices are preserved. Edit this section or continue.")
571
+ self._show_step()
572
+ else:
573
+ self.busy = True
574
+ self._set_status("Checking available setup options…")
575
+ self._show_step(focus=False)
576
+ self._initialize()
577
+ elif button == "done":
578
+ result = self.result if self.result is not None else {**copy.deepcopy(self.preview_result or {}),
579
+ "applied": False, "dry_run": True}
580
+ self._finish(result)
581
+ elif button == "back" and self.step >= 0 and self.result is None:
582
+ self._collect(validate=False)
583
+ self.step -= 1
584
+ self.preview_result = None
585
+ self._set_status("Your choices are preserved. Edit this section or continue.")
586
+ self._show_step()
587
+ elif button == "add-project":
588
+ self._add_project()
589
+ elif button == "clear-projects":
590
+ self.plan["project_roots"] = []
591
+ self.plan["import_paths"] = list(self.query_one("#source-choices", SelectionList).selected)
592
+ self._discover()
593
+ elif button == "next":
594
+ if self.step == 1 and self.query_one("#project-path", Input).value.strip():
595
+ self._add_project()
596
+ return
597
+ try:
598
+ self._collect()
599
+ except ValueError as exc:
600
+ self._set_status(str(exc))
601
+ return
602
+ if self.step == 2:
603
+ self.busy = True
604
+ self._show_step(focus=False)
605
+ self._set_status("Validating the exact setup plan…")
606
+ self._preview_worker(copy.deepcopy(self.plan))
607
+ else:
608
+ self.step += 1
609
+ if self.step == 2:
610
+ self._set_status("Select missing capabilities to install. Next shows the exact effects before Apply.")
611
+ self._show_step()
612
+ if self.step == 1:
613
+ self._discover()
614
+ elif button == "apply" and self.preview_result is not None and not self.dry_run:
615
+ self.cancel_requested.clear()
616
+ self.started_effects = False
617
+ self.applying = True
618
+ self.busy = True
619
+ self._show_step(focus=False)
620
+ self._set_status("Applying the accepted setup. Completed changes will be reported.")
621
+ self._apply_worker(copy.deepcopy(self.plan), copy.deepcopy(self.preview_result))
622
+
623
+ def _finish(self, result) -> None:
624
+ self.closing = True
625
+ self.exit(result)
626
+
627
+ def action_cancel(self) -> None:
628
+ if self.applying:
629
+ self.cancel_requested.set()
630
+ self._set_status("Stop requested. Waiting for the current step to finish safely; completed changes are retained.")
631
+ elif self.result is not None:
632
+ self._finish(self.result)
633
+ else:
634
+ self._finish({"cancelled": True, "applied": False})
635
+
636
+ def action_quit(self) -> None:
637
+ self.action_cancel()
638
+
639
+
640
+ def run_setup_ui(installer: Any, *, dry_run: bool = False,
641
+ initial_plan: dict[str, Any] | None = None) -> dict[str, Any]:
642
+ app = SetupApp(installer, dry_run=dry_run, initial_plan=initial_plan)
643
+ result = app.run()
644
+ if app.backend_error is not None:
645
+ app.backend_error.ui_language = app.language
646
+ raise app.backend_error
647
+ return {**(result if result is not None else {"cancelled": True, "applied": False}), "ui_language": app.language}