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.
- package/DEPENDENCIES.md +236 -80
- package/INSTALL.md +112 -0
- package/README.md +184 -524
- package/claude/CLAUDE.md +1 -1
- package/claude/guides/cli-multi-model-workflow.md +1 -1
- package/claude/guides/learning-flow.md +23 -12
- package/claude/guides/session-distill-workflow.md +22 -12
- package/codex/AGENTS.md +1 -1
- package/codex/guides/cli-multi-model-workflow.md +1 -1
- package/codex/guides/learning-flow.md +23 -12
- package/codex/guides/session-distill-workflow.md +22 -12
- package/compose/app_bridge/SKILL.md +75 -0
- package/compose/app_bridge/agents/openai.yaml +2 -0
- package/compose/app_bridge/scripts/bridge.py +76 -0
- package/compose/bootstrap/SKILL.md +12 -1
- package/compose/corpus.py +31 -9
- package/compose/corpus_app.py +456 -0
- package/compose/corpus_import.py +529 -0
- package/compose/corpus_install.py +196 -18
- package/compose/corpus_session.py +27 -0
- package/compose/corpus_setup.py +674 -0
- package/compose/corpus_setup_cli.py +582 -0
- package/compose/corpus_setup_i18n.py +318 -0
- package/compose/corpus_setup_ui.py +633 -0
- package/compose/corpus_store.py +167 -29
- package/compose/corpus_transaction.py +43 -10
- package/compose/corpus_ui_runtime.py +278 -0
- package/compose/setup/START.md +147 -0
- package/compose/ui_runtime/linkify_it_py-2.2.0-py3-none-any.whl +0 -0
- package/compose/ui_runtime/manifest.json +238 -0
- package/compose/ui_runtime/markdown_it_py-4.2.0-py3-none-any.whl +0 -0
- package/compose/ui_runtime/mdit_py_plugins-0.6.1-py3-none-any.whl +0 -0
- package/compose/ui_runtime/mdurl-0.1.2-py3-none-any.whl +0 -0
- package/compose/ui_runtime/platformdirs-4.11.8-py3-none-any.whl +0 -0
- package/compose/ui_runtime/pygments-2.21.0-py3-none-any.whl +0 -0
- package/compose/ui_runtime/rich-15.0.0-py3-none-any.whl +0 -0
- package/compose/ui_runtime/textual-8.2.8-py3-none-any.whl +0 -0
- package/compose/ui_runtime/typing_extensions-4.16.0-py3-none-any.whl +0 -0
- package/docs/advanced-launch.md +131 -0
- package/docs/assets/corpus-studio.svg +227 -0
- package/docs/corpus.md +117 -0
- package/docs/recovery.md +201 -0
- package/docs/session-model.md +120 -0
- package/docs/setup.md +190 -0
- package/docs/understand.md +40 -0
- package/install.sh +75 -46
- package/launch/agent-launch.py +91 -47
- package/launch/provision-venv.sh +44 -13
- package/learn/collect-learning.py +14 -5
- package/learn/learning.schema.json +2 -2
- package/package.json +14 -2
- package/provenance.json +1 -1
- 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}
|