agent-bios 0.18.0 → 0.19.0

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