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,674 @@
1
+ """Shared setup controller, read-only dependency inventory, and numbered client."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import platform
9
+ import re
10
+ import shlex
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from typing import Any, Callable, TextIO
15
+
16
+ try:
17
+ from corpus_setup_i18n import choice_label, dependency_display, translate
18
+ except ImportError:
19
+ from .corpus_setup_i18n import choice_label, dependency_display, translate
20
+
21
+
22
+ class SetupError(RuntimeError):
23
+ pass
24
+
25
+
26
+ class _Back(Exception):
27
+ pass
28
+
29
+
30
+ class _Cancel(Exception):
31
+ pass
32
+
33
+
34
+ def _display(value: str) -> str:
35
+ return re.sub(r"[\x00-\x1f\x7f-\x9f]", " ", value)[:240].strip()
36
+
37
+
38
+ def dependency_inventory(repo: Path, environ: dict[str, str] | None = None, *,
39
+ runner: Callable = subprocess.run,
40
+ which: Callable = shutil.which,
41
+ system: str | None = None) -> list[dict[str, Any]]:
42
+ """Probe local commands without installing, authenticating, or fetching data."""
43
+ env = dict(os.environ if environ is None else environ)
44
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
45
+ home = Path(env.get("HOME", str(Path.home())))
46
+ system = system or platform.system()
47
+ supported = system in {"Darwin", "Linux"}
48
+ paths = {name: which(name, path=env.get("PATH", os.defpath))
49
+ for name in ("bash", "python3", "node", "npm", "brew", "git", "zsh", "claude", "codex", "cp", "mktemp")}
50
+
51
+ def probe(argv: list[str]) -> tuple[bool, str]:
52
+ try:
53
+ result = runner(argv, shell=False, stdin=subprocess.DEVNULL, capture_output=True,
54
+ text=True, timeout=5, env=env)
55
+ text = (result.stdout or result.stderr or "").splitlines()
56
+ return result.returncode == 0, _display(text[0] if text else "")
57
+ except (OSError, subprocess.SubprocessError) as exc:
58
+ return False, _display(str(exc))
59
+
60
+ rows: list[dict[str, Any]] = []
61
+
62
+ def add(identifier: str, title: str, role: str, purpose: str, *,
63
+ path: str | None = None, argv: list[str] | None = None,
64
+ action: list[str] | None = None, reason: str = "", scope: str = "",
65
+ present: bool | None = None, version: str = "") -> dict[str, Any]:
66
+ okay, detail = probe(argv) if argv else (bool(path) if present is None else present, version)
67
+ row = {"id": identifier, "title": title, "role": role, "purpose": purpose,
68
+ "status": "available" if okay else "missing", "path": path, "version": detail,
69
+ "install_argv": action if not okay and supported else None,
70
+ "install_scope": scope, "manual_reason": reason if not okay else ""}
71
+ if not supported:
72
+ row["manual_reason"] = "This installer supports macOS and Linux."
73
+ rows.append(row)
74
+ return row
75
+
76
+ brew_row = add("homebrew", "Homebrew", "optional package manager", "Offers local package installation recipes when available.",
77
+ path=paths["brew"], argv=[paths["brew"], "--version"] if paths["brew"] else None,
78
+ reason="Optional: use an existing operating-system package manager or the official host installer.")
79
+ brew = paths["brew"] if brew_row["status"] == "available" else None
80
+
81
+ def formula(name: str) -> list[str] | None:
82
+ return [brew, "install", name] if brew else None
83
+
84
+ add("bash", "Bash", "runtime", "Runs the package entry point and bundled shell tools.",
85
+ path=paths["bash"], argv=[paths["bash"], "--version"] if paths["bash"] else None,
86
+ action=formula("bash"), scope="Homebrew prefix", reason="Install Bash using your operating system package manager.")
87
+ py = paths["python3"]
88
+ add("python3", "Python 3.11+", "runtime", "Runs installation, corpus storage, and terminal interfaces.", path=py,
89
+ argv=[py, "-c", "import sys; print(sys.version.split()[0]); raise SystemExit(sys.version_info < (3,11))"] if py else None,
90
+ action=formula("python"), scope="Homebrew prefix", reason="Install Python 3.11+ and ensure python3 resolves to it.")
91
+ node = paths["node"]
92
+ node_row = add("node", "Node.js 22+", "delivery / optional host install", "Supports npm host installation and optional slide rendering.", path=node,
93
+ argv=[node, "--version"] if node else None, action=formula("node"), scope="Homebrew prefix",
94
+ reason="Install a current Node.js distribution with npm; the corpus runtime itself does not need Node.")
95
+ node_major = re.match(r"v?(\d+)", node_row["version"])
96
+ major = int(node_major[1]) if node_major and node_row["status"] == "available" else 0
97
+ if node_row["status"] == "available" and major < 22:
98
+ node_row["status"] = "missing"
99
+ if supported:
100
+ node_row.update(install_argv=formula("node"), manual_reason="Node.js 22+ is required by the Claude npm installer.")
101
+ npm = paths["npm"]
102
+ npm_row = add("npm", "npm", "delivery / optional host install", "Installs the package and selected host CLIs.", path=npm,
103
+ argv=[npm, "--version"] if npm else None,
104
+ reason="npm comes with Node.js; install Node.js first and rerun setup.")
105
+ npm = npm if npm_row["status"] == "available" else None
106
+ for identifier, title, package, minimum in (("codex", "Codex CLI", "@openai/codex", 18),
107
+ ("claude", "Claude Code", "@anthropic-ai/claude-code", 22)):
108
+ action = [brew, "install", "--cask", "codex" if identifier == "codex" else "claude-code"] if brew else (
109
+ [npm, "install", "-g", package] if npm and major >= minimum else None)
110
+ path = paths[identifier]
111
+ add(identifier, title, "selected host", "Required to launch this host; sign-in is a separate step.", path=path,
112
+ argv=[path, "--version"] if path else None, action=action,
113
+ scope="Homebrew prefix and required dependencies" if brew else "npm global prefix",
114
+ reason=f"Use the official installer, or install npm with Node.js {minimum}+ and rerun setup.")
115
+ for identifier, title, role, purpose in (
116
+ ("git", "Git", "workflow", "Clone updates, worktrees, and version-control workflows."),
117
+ ("zsh", "zsh", "optional shell connection", "Optional interception of bare host commands."),
118
+ ):
119
+ path = paths[identifier]
120
+ add(identifier, title, role, purpose, path=path, argv=[path, "--version"] if path else None,
121
+ action=formula(identifier), scope="Homebrew prefix", reason="Install with your operating system package manager.")
122
+ for identifier in ("cp", "mktemp"):
123
+ add(identifier, identifier, "shell adapters", "Required by optional shell worker adapters.", path=paths[identifier],
124
+ reason="Install the standard BSD or GNU command-line utilities for your system.")
125
+ venv = Path(env.get("AGENT_LAUNCH_VENV") or str(home / ".local/share/agent-launch/venv"))
126
+ vpy = venv / "bin/python"
127
+ configured_python = env.get("AGENT_LAUNCH_PYTHON")
128
+ bootstrap_python = which(configured_python, path=env.get("PATH", os.defpath)) if configured_python else py
129
+ provisioner = Path(repo) / "launch/provision-venv.sh"
130
+ pins = {}
131
+ if provisioner.is_file():
132
+ pins = dict(re.findall(r'\b(?:TEXTUAL|JSONSCHEMA)_PIN="([a-z]+)==([0-9][a-zA-Z0-9_.-]*)"', provisioner.read_text(encoding="utf-8")))
133
+ if set(pins) != {"textual", "jsonschema"}:
134
+ raise SetupError("Bundled managed dependency pins are incomplete; verify or reinstall the agent-bios package.")
135
+
136
+ def module_probe(interpreter: str, name: str, *, managed: bool = False) -> list[str]:
137
+ code = f"import {name}; import importlib.metadata; actual = importlib.metadata.version({name!r}); print(actual)"
138
+ if name == "jsonschema":
139
+ code += "; from jsonschema import Draft202012Validator"
140
+ if managed and name in pins:
141
+ code += f"; import sys; raise SystemExit(sys.version_info < (3, 11) or actual != {pins[name]!r})"
142
+ return [interpreter, "-c", code]
143
+
144
+ bootstrap = add("python-venv", "Python venv / pip bootstrap", "managed dependency prerequisite", "Creates the managed environment using AGENT_LAUNCH_PYTHON when set, otherwise python3.", path=bootstrap_python,
145
+ argv=[bootstrap_python, "-c", "import sys, venv, ensurepip; print('venv; bundled pip ' + ensurepip.version()); raise SystemExit(sys.version_info < (3, 11))"] if bootstrap_python else None,
146
+ reason="Install venv/ensurepip for Python 3.11+; check AGENT_LAUNCH_PYTHON if configured. An existing managed environment does not need this bootstrap.")
147
+ can_provision = bool(paths["bash"] and provisioner.is_file() and (vpy.is_file() or bootstrap["status"] == "available"))
148
+ try:
149
+ from .corpus_ui_runtime import runtime_inventory
150
+ except ImportError:
151
+ from corpus_ui_runtime import runtime_inventory
152
+ ui = runtime_inventory(Path(repo))
153
+ bundle = str(Path(repo) / "compose/ui_runtime")
154
+ textual_version = next((row["version"] for row in ui.get("packages", []) if row["name"] == "textual"), "")
155
+ add("textual", "Textual (included)", "bundled UI runtime", "The terminal UI uses verified bundled packages without a system or managed Textual installation.",
156
+ path=bundle, present=ui["status"] == "available", version=textual_version,
157
+ scope="process-owned temporary directory",
158
+ reason="The shipped UI bundle is unavailable; verify or reinstall this agent-bios package. " + "; ".join(ui.get("issues", [])))
159
+ for package in ui.get("packages", []):
160
+ if package["name"] == "textual":
161
+ continue
162
+ add("ui-" + package["name"], package["name"] + " (included)", "bundled UI dependency",
163
+ "Included in the Textual runtime; no separate installation is needed.",
164
+ path=bundle, present=True, version=package["version"], scope="process-owned temporary directory")
165
+ learning_ready, learning_version = probe(module_probe(py, "jsonschema")) if py else (False, "")
166
+ learning_python = py
167
+ if not learning_ready and vpy.is_file():
168
+ learning_ready, learning_version = probe(module_probe(str(vpy), "jsonschema", managed=True))
169
+ learning_python = str(vpy)
170
+ add("jsonschema", "jsonschema", "learning capture", "Validates end-user learn submissions against JSON Schema Draft 2020-12, and also serves the author gate.",
171
+ path=learning_python, present=learning_ready, version=learning_version,
172
+ action=[paths["bash"], str(provisioner), "--learning-only"] if can_provision else None,
173
+ scope=str(venv), reason="Select the managed learning validator installation; learn uses it when system Python lacks jsonschema.")
174
+ for identifier, title, purpose in (
175
+ ("slide-playwright", "Playwright module", "Static slide jobs bind an explicit Playwright module file."),
176
+ ("slide-pdf-lib", "pdf-lib", "Static slide jobs resolve pdf-lib beside the selected Playwright module."),
177
+ ("slide-browser", "Chromium-family browser", "Static slide jobs bind an explicit browser executable."),
178
+ ("spreadsheet-processing", "Spreadsheet-processing skill", "Selected spreadsheet guidance can use this optional personal skill."),
179
+ ("mcp-servers", "User-specific MCP servers", "Only user-selected workflows require their configured external services."),
180
+ ):
181
+ row = add(identifier, title, "optional job / personal integration", purpose, present=False,
182
+ reason="Configure this only for a workflow that requires it; setup cannot choose your job environment or account.")
183
+ row["status"] = "not assessed"
184
+ return rows
185
+
186
+
187
+ def catalog_choices(installer: Any) -> list[dict[str, str]]:
188
+ if hasattr(installer, "setup_catalog"):
189
+ catalog = installer.setup_catalog()
190
+ else:
191
+ try:
192
+ from corpus_catalog import load_catalog
193
+ except ImportError:
194
+ from .corpus_catalog import load_catalog
195
+ catalog = load_catalog(installer.repo)
196
+ choices: list[dict[str, str]] = []
197
+ for package in catalog["packages"]:
198
+ package_id = package["package_id"]
199
+ choices.append({"target": package_id, "label": "All content in " + package_id})
200
+ for name, description in sorted(package.get("domains", {}).items()):
201
+ choices.append({"target": package_id + "/" + name, "label": f"{description} ({package_id}/{name})"})
202
+ if not choices:
203
+ raise SetupError("No corpus packages are available for selection.")
204
+ return choices
205
+
206
+
207
+ def _indexes(value: str, count: int) -> list[int]:
208
+ if value.lower() in {"", "none"}:
209
+ return []
210
+ try:
211
+ indexes = sorted({int(part.strip()) - 1 for part in value.split(",")})
212
+ except ValueError as exc:
213
+ raise SetupError("Enter comma-separated numbers, or none.") from exc
214
+ if any(index < 0 or index >= count for index in indexes):
215
+ raise SetupError("A selected number is outside the displayed list.")
216
+ return indexes
217
+
218
+
219
+ def format_setup_result(result: dict[str, Any], language: str = "en", *, interface: str = "terminal") -> str:
220
+ """Summarize completion without exposing the runtime file inventory."""
221
+ if interface not in {"terminal", "conversation"}:
222
+ raise ValueError("unknown setup presentation")
223
+ def t(message: str, **values: Any) -> str:
224
+ return translate(language, message, **values)
225
+
226
+ def recovery_hint() -> str:
227
+ return t("Use the returned review_id with agent-bios setup status or agent-bios setup resume before continuing.")
228
+
229
+ if result.get("cancelled"):
230
+ if result.get("cancelled_after_start"):
231
+ lines = [t("Setup stopped after the current operation finished.")]
232
+ completed = [row["id"] for row in result.get("dependency_results", []) if row.get("returncode") == 0]
233
+ if completed:
234
+ lines.append(t("Dependencies retained: {dependencies}.", dependencies=", ".join(completed)))
235
+ if result.get("installation_applied"):
236
+ lines.append(t("The private runtime installation is retained; remaining setup was not applied."))
237
+ else:
238
+ lines.append(t("The private runtime installation was not applied."))
239
+ if interface == "conversation":
240
+ lines.append(recovery_hint())
241
+ return "\n".join(lines)
242
+ return t("Setup cancelled. No installation changes were applied.")
243
+ if result.get("dry_run"):
244
+ return t("Setup preview complete. No installation changes were applied.")
245
+ completed = [row["id"] for row in result.get("dependency_results", []) if row.get("returncode") == 0]
246
+ if result.get("installation_error"):
247
+ lines = [t("Private runtime installation needs attention: {error}", error=str(result["installation_error"]))]
248
+ if completed:
249
+ lines.append(t("Dependencies retained: {dependencies}.", dependencies=", ".join(completed)))
250
+ lines.append(recovery_hint() if interface == "conversation" else t("Inspect the reported state and rerun agent-bios install."))
251
+ return "\n".join(lines)
252
+ if result.get("dependency_failed"):
253
+ lines = [t("Dependency installation failed: {dependency}. The private runtime was not installed by this setup.", dependency=result["dependency_failed"])]
254
+ if completed:
255
+ lines.append(t("Dependencies already installed: {dependencies}.", dependencies=", ".join(completed)))
256
+ lines.append(recovery_hint() if interface == "conversation" else t("Resolve the dependency error and run agent-bios install --interactive again."))
257
+ return "\n".join(lines)
258
+ if result.get("extras_error"):
259
+ lines = [t("Private runtime installed; app registration or instruction capture needs attention."),
260
+ str(result["extras_error"])]
261
+ if (result.get("extras") or {}).get("app_bridge", {}).get("registered"):
262
+ lines.append(t("The app command registration is retained; instruction capture did not complete."))
263
+ if completed:
264
+ lines.append(t("Dependencies installed: {dependencies}.", dependencies=", ".join(completed)))
265
+ lines.append(recovery_hint() if interface == "conversation" else t("Open Corpus Studio: agent-bios corpus (terminal or Codex app terminal panel)."))
266
+ return "\n".join(lines)
267
+ if not result.get("applied"):
268
+ return t("Setup did not complete.")
269
+ plan = result.get("plan", {})
270
+ lines = [t("Setup complete. Private runtime installed.")]
271
+ if completed:
272
+ lines.append(t("Dependencies installed: {dependencies}.", dependencies=", ".join(completed)))
273
+ mode, targets = plan.get("selection_mode"), plan.get("targets") or []
274
+ if mode == "none":
275
+ lines.append(t("Corpus for future activated sessions: none."))
276
+ elif mode == "selected":
277
+ corpus = t("all available corpus") if targets == ["all"] else ", ".join(targets)
278
+ lines.append(t("Corpus for future activated sessions: {corpus}.", corpus=corpus))
279
+ elif mode == "default":
280
+ lines.append(t("Corpus policy: core, infrastructure and personal corpus; selected domains: {domains}.", domains=", ".join(targets))
281
+ if targets else t("Corpus policy: core, infrastructure and personal corpus."))
282
+ else:
283
+ lines.append(t("Saved corpus policy and item choices preserved."))
284
+ extras = result.get("extras") or {}
285
+ bridge = extras.get("app_bridge") or (result.get("installation") or {}).get("app_bridge") or {}
286
+ if bridge.get("needs_action"):
287
+ lines.append(t("Codex app bridge needs attention; existing files were preserved:"))
288
+ lines.extend("- " + str(message) for message in bridge["needs_action"])
289
+ if bridge.get("registered"):
290
+ lines.append(t("Codex app bridge registered: use $agent-bios for per-task preview/use/off and corpus management."))
291
+ captured = extras.get("import") or {}
292
+ if captured.get("capture_id"):
293
+ count = captured.get("source_count")
294
+ lines.append(t("Instruction capture: {capture_id} ({count} source files). Model review is required before activation.",
295
+ capture_id=captured["capture_id"], count=count) if count is not None else
296
+ t("Instruction capture: {capture_id}. Model review is required before activation.", capture_id=captured["capture_id"]))
297
+ if captured.get("next_command"):
298
+ lines.append(t("Next: {command}", command=str(captured["next_command"])))
299
+ if bridge.get("registered") and captured.get("app_request"):
300
+ request = str(captured["app_request"])
301
+ if request == f"Use $agent-bios to import capture {captured['capture_id']}":
302
+ request = t("Use $agent-bios to import capture {capture_id}", capture_id=captured["capture_id"])
303
+ lines.append(t("In Codex: {request}", request=request))
304
+ lines.append(t("Continue in the conversation using the returned verified entrypoint.") if interface == "conversation"
305
+ else t("Open Corpus Studio: agent-bios corpus (terminal or Codex app terminal panel)."))
306
+ return "\n".join(lines)
307
+
308
+
309
+ def review_summary(plan: dict[str, Any], dependencies: list[dict[str, Any]],
310
+ choices: list[dict[str, Any]], language: str = "en") -> str:
311
+ """Render the shared human review without importing a UI framework."""
312
+ def t(message: str, **values: Any) -> str:
313
+ return translate(language, message, **values)
314
+ labels = {row["target"]: choice_label(language, row) for row in choices}
315
+ labels["all"] = t("All available corpus")
316
+ mode, targets = plan.get("selection_mode"), plan.get("targets") or []
317
+ if mode is None:
318
+ corpus = t("Keep the saved corpus policy and item choices")
319
+ elif mode == "none":
320
+ corpus = t("No active corpus")
321
+ elif targets == ["all"]:
322
+ corpus = t("All available corpus")
323
+ else:
324
+ corpus = ", ".join(labels.get(value, value) for value in targets) or t("No selected items")
325
+ selected = set(plan.get("dependencies") or [])
326
+ installs = [dependency_display(language, row) for row in dependencies if row["id"] in selected]
327
+ lines = [t("Ready to apply"), "", t("Corpus: {selection}", selection=corpus),
328
+ t("App connection: {connection}", connection=t("Register $agent-bios for explicit task use") if plan.get("app_bridge") else t("No new app registration"))]
329
+ if installs:
330
+ lines.append(t("Install:"))
331
+ lines.extend(" " + row["title"] + (" — " + row["install_scope"] if row.get("install_scope") else "") for row in installs)
332
+ else:
333
+ lines.append(t("Install dependencies: none"))
334
+ sources = plan.get("import_paths") or []
335
+ lines.append(t("Prepare for model review: {count} instruction file(s)", count=len(sources)))
336
+ lines.extend(" " + path for path in sources)
337
+ lines.extend(["", t("Native global and project instruction files are preserved."),
338
+ t("This setup does not add corpus to the current app task."),
339
+ t("Library files remain stored privately when active corpus is off.")])
340
+ if sources:
341
+ lines.append(t("Captured instructions need a separate semantic review before import."))
342
+ return re.sub(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]", " ", "\n".join(lines))
343
+
344
+
345
+ class SetupController:
346
+ """One read-only plan and selected execution path for installation clients."""
347
+
348
+ FIELDS = {"selection_mode", "targets", "dependencies", "app_bridge", "import_paths", "project_roots"}
349
+
350
+ def __init__(self, installer: Any, *, runner: Callable = subprocess.run,
351
+ inventory: list[dict[str, Any]] | None = None, extras_handler: Callable | None = None):
352
+ self.installer = installer
353
+ self.runner = runner
354
+ self.dependencies = inventory if inventory is not None else dependency_inventory(installer.repo, installer.env, runner=runner)
355
+ self.choices = catalog_choices(installer)
356
+ self.handler = extras_handler or getattr(installer, "setup_extras", None)
357
+ identifiers = [row["id"] for row in self.dependencies]
358
+ if len(identifiers) != len(set(identifiers)):
359
+ raise SetupError("Dependency inventory has duplicate identifiers.")
360
+
361
+ def default_plan(self, selection_mode: str | None = None, targets: list[str] | None = None) -> dict[str, Any]:
362
+ if selection_mode is None and targets is None:
363
+ status = getattr(self.installer, "status", None)
364
+ selection_mode = None if callable(status) and status().get("installed") else "none"
365
+ return {"selection_mode": selection_mode,
366
+ "targets": list(targets) if targets is not None else (None if selection_mode is None else []),
367
+ "dependencies": [], "app_bridge": False, "import_paths": [], "project_roots": []}
368
+
369
+ def discover(self, project_roots: list[str]) -> dict[str, Any]:
370
+ if not isinstance(project_roots, list) or not all(isinstance(value, str) and Path(value).is_absolute()
371
+ and Path(value).is_dir() for value in project_roots):
372
+ raise SetupError("Project roots must be existing absolute directories.")
373
+ discovery = getattr(self.installer, "setup_discover", None)
374
+ if not callable(discovery):
375
+ raise SetupError("Instruction discovery is unavailable in this installer.")
376
+ found = discovery(project_roots)
377
+ return found if isinstance(found, dict) else {"sources": found, "omitted": []}
378
+
379
+ def _plan(self, value: dict[str, Any]) -> dict[str, Any]:
380
+ if not isinstance(value, dict) or set(value) - self.FIELDS - {"dependency_actions"}:
381
+ raise SetupError("Installation plan contains unsupported fields.")
382
+ if not self.FIELDS <= set(value):
383
+ raise SetupError("Installation plan is incomplete.")
384
+ plan = {key: value[key] for key in self.FIELDS}
385
+ mode, targets = plan["selection_mode"], plan["targets"]
386
+ if (mode is not None and not isinstance(mode, str)) or mode not in {None, "default", "selected", "none"}:
387
+ raise SetupError("Unsupported corpus selection mode.")
388
+ if targets is not None and (not isinstance(targets, list) or not all(isinstance(x, str) and x for x in targets)):
389
+ raise SetupError("Corpus targets must be a list of qualified names.")
390
+ if mode == "selected" and not targets:
391
+ raise SetupError("Select at least one corpus, or choose no active corpus.")
392
+ if mode == "none" and targets:
393
+ raise SetupError("No active corpus cannot include selected targets.")
394
+ if mode is None and targets:
395
+ raise SetupError("Keeping saved selection cannot specify new targets.")
396
+ if type(plan["app_bridge"]) is not bool:
397
+ raise SetupError("App registration must be an explicit boolean choice.")
398
+ for key in ("dependencies", "import_paths", "project_roots"):
399
+ items = plan[key]
400
+ if not isinstance(items, list) or not all(isinstance(x, str) and x for x in items) or len(set(items)) != len(items):
401
+ raise SetupError(f"{key} must be a list of distinct values.")
402
+ available = {row["id"]: row for row in self.dependencies}
403
+ for name in plan["dependencies"]:
404
+ if name not in available or not available[name].get("install_argv"):
405
+ raise SetupError(f"Dependency has no reviewed installation recipe: {name}")
406
+ if (plan["app_bridge"] or plan["import_paths"]) and not callable(self.handler):
407
+ raise SetupError("The requested app/import integration is unavailable.")
408
+ return json.loads(json.dumps(plan))
409
+
410
+ def _sources(self, plan: dict[str, Any]) -> list[dict[str, str]]:
411
+ if not plan["import_paths"]:
412
+ return []
413
+ found = self.discover(plan["project_roots"])
414
+ allowed = {row["path"]: row for row in found["sources"]}
415
+ if not set(plan["import_paths"]) <= set(allowed):
416
+ raise SetupError("Instruction selection is outside the current discovery set.")
417
+ try:
418
+ from .corpus_import import _read_source
419
+ except ImportError:
420
+ from corpus_import import _read_source
421
+ return [{"path": path, "sha256": hashlib.sha256(_read_source(allowed[path])[0]).hexdigest()}
422
+ for path in plan["import_paths"]]
423
+
424
+ def _install(self, plan: dict[str, Any], dry_run: bool) -> dict[str, Any]:
425
+ if plan["selection_mode"] is None:
426
+ return self.installer.install(dry_run=dry_run)
427
+ return self.installer.install(dry_run=dry_run, selection_mode=plan["selection_mode"], targets=plan["targets"])
428
+
429
+ def preview(self, value: dict[str, Any]) -> dict[str, Any]:
430
+ plan = self._plan(value)
431
+ actions = [row for row in self.dependencies if row["id"] in plan["dependencies"]]
432
+ concrete = dict(plan, dependency_actions=[{"id": row["id"], "argv": list(row["install_argv"]),
433
+ "scope": row["install_scope"]} for row in actions])
434
+ sources = self._sources(plan)
435
+ result = {"plan": concrete, "installation": self._install(plan, True),
436
+ "extras": self.handler(plan, dry_run=True) if plan["app_bridge"] or plan["import_paths"] else None,
437
+ "source_versions": sources}
438
+ revision = getattr(self.installer, "setup_revision", None)
439
+ if callable(revision):
440
+ result["state_revision"] = revision()
441
+ result["review_id"] = hashlib.sha256(json.dumps(result, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
442
+ return result
443
+
444
+ def apply(self, value: dict[str, Any], preview: dict[str, Any] | None = None, *,
445
+ progress: Callable[[dict[str, Any]], None] | None = None,
446
+ should_cancel: Callable[[], bool] | None = None,
447
+ reuse_installation: bool = False) -> dict[str, Any]:
448
+ if type(reuse_installation) is not bool:
449
+ raise SetupError("reuse_installation must be boolean")
450
+ checked = self.preview(value)
451
+ if preview is not None and checked != preview:
452
+ raise SetupError("Installation inputs changed after review; inspect a fresh plan before Apply.")
453
+ plan = self._plan(checked["plan"])
454
+ result: dict[str, Any] = {"applied": False, "plan": checked["plan"], "dependency_results": []}
455
+
456
+ def emit(stage: str, message: str, **extra) -> None:
457
+ if progress:
458
+ progress({"stage": stage, "message": message, **extra})
459
+
460
+ def cancelled() -> bool:
461
+ if should_cancel and should_cancel():
462
+ result.update(cancelled=True, cancelled_after_start=bool(result["dependency_results"] or result.get("installation_applied")))
463
+ return True
464
+ return False
465
+
466
+ for action in checked["plan"]["dependency_actions"]:
467
+ if cancelled():
468
+ return result
469
+ emit("dependency", "Installing " + action["id"], dependency=action["id"], phase="started")
470
+ try:
471
+ process = self.runner(action["argv"], shell=False, stdin=subprocess.DEVNULL,
472
+ env=dict(self.installer.env), check=False, capture_output=True,
473
+ text=True, errors="replace")
474
+ except OSError as exc:
475
+ outcome = {"id": action["id"], "returncode": None, "stdout": "", "stderr": str(exc)}
476
+ result["dependency_results"].append(outcome)
477
+ result["dependency_failed"] = action["id"]
478
+ emit("dependency", "Could not start " + action["id"], phase="not_started", **outcome)
479
+ return result
480
+ outcome = {"id": action["id"], "returncode": process.returncode,
481
+ "stdout": (getattr(process, "stdout", "") or "")[-16000:],
482
+ "stderr": (getattr(process, "stderr", "") or "")[-16000:]}
483
+ result["dependency_results"].append(outcome)
484
+ emit("dependency", "Finished " + action["id"], phase="completed" if process.returncode == 0 else "failed", **outcome)
485
+ if process.returncode:
486
+ result["dependency_failed"] = action["id"]
487
+ return result
488
+ if cancelled():
489
+ return result
490
+ emit("install", "Installing the private runtime", phase="checking")
491
+ try:
492
+ revision = getattr(self.installer, "setup_revision", None)
493
+ if callable(revision) and revision() != checked.get("state_revision"):
494
+ raise SetupError("Private state changed after review; review the remaining installation again.")
495
+ if self._sources(plan) != checked["source_versions"]:
496
+ raise SetupError("Instruction sources changed after review; select and review them again.")
497
+ if self._install(plan, True) != checked["installation"]:
498
+ raise SetupError("Package or corpus selection changed after review; inspect a fresh plan.")
499
+ if reuse_installation:
500
+ verified = self.installer.verify()
501
+ status = self.installer.status()
502
+ installed_root = status.get("package_root")
503
+ digest = Path(installed_root).name if isinstance(installed_root, str) else None
504
+ if verified.get("stored") is not True or digest != checked["installation"].get("release_digest"):
505
+ raise SetupError("the reviewed package does not match the retained installation")
506
+ result["installation"] = {**status, "verified": verified, "release_digest": digest}
507
+ result["installation_reused"] = True
508
+ else:
509
+ emit("install", "Installing the private runtime", phase="started")
510
+ result["installation"] = self._install(plan, False)
511
+ result["installation_applied"] = True
512
+ emit("install", "Installing the private runtime", phase="completed", installation=result["installation"],
513
+ reused=reuse_installation)
514
+ except (OSError, RuntimeError, ValueError) as exc:
515
+ result["installation_error"] = str(exc)
516
+ return result
517
+ if cancelled():
518
+ return result
519
+ try:
520
+ if plan["app_bridge"] or plan["import_paths"]:
521
+ emit("extras", "Preparing app registration and selected instruction capture", phase="checking")
522
+ if self._sources(plan) != checked["source_versions"]:
523
+ raise SetupError("Instruction sources changed after review; review a new capture before continuing.")
524
+ source_digests = {row["path"]: row["sha256"] for row in checked["source_versions"]}
525
+ emit("extras", "Preparing app registration and selected instruction capture", phase="started")
526
+ result["extras"] = self.handler(dict(plan, _expected_source_digests=source_digests), dry_run=False)
527
+ emit("extras", "Preparing app registration and selected instruction capture", phase="completed", extras=result["extras"])
528
+ else:
529
+ result["extras"] = None
530
+ except (OSError, RuntimeError, ValueError) as exc:
531
+ result["extras_error"] = str(exc)
532
+ if getattr(exc, "completed_extras", None):
533
+ result["extras"] = exc.completed_extras
534
+ return result
535
+ result["applied"] = True
536
+ emit("complete", "Setup complete")
537
+ return result
538
+
539
+
540
+ def run_setup(installer: Any, *, input_fn: Callable[[str], str] | None = None,
541
+ input_stream: TextIO | None = None, output_stream: TextIO | None = None,
542
+ runner: Callable = subprocess.run, dry_run: bool = False,
543
+ inventory: list[dict[str, Any]] | None = None,
544
+ extras_handler: Callable | None = None) -> dict[str, Any]:
545
+ """Collect a complete plan, then apply only the user's explicit selection."""
546
+ out = output_stream or sys.stdout
547
+ source = input_stream or sys.stdin
548
+ controller = SetupController(installer, runner=runner, inventory=inventory, extras_handler=extras_handler)
549
+ dependencies = controller.dependencies
550
+ choices = controller.choices
551
+ plan: dict[str, Any] = {"selection_mode": "none", "targets": [], "dependencies": [],
552
+ "app_bridge": False, "import_paths": [], "project_roots": []}
553
+
554
+ def write(text: str = "") -> None:
555
+ print(text, file=out, flush=True)
556
+
557
+ def ask(prompt: str) -> str:
558
+ try:
559
+ if input_fn is not None:
560
+ answer = input_fn(prompt)
561
+ else:
562
+ print(prompt, end="", file=out, flush=True)
563
+ answer = source.readline()
564
+ if not answer:
565
+ raise _Cancel()
566
+ value = answer.strip()
567
+ if value.lower() in {"cancel", "q", "quit"}:
568
+ raise _Cancel()
569
+ if value.lower() in {"back", "b"}:
570
+ raise _Back()
571
+ return value
572
+ except (EOFError, KeyboardInterrupt, StopIteration):
573
+ raise _Cancel() from None
574
+
575
+ write("agent-bios setup — back returns to the previous step; cancel exits without applying.")
576
+ write("Native AGENTS.md and CLAUDE.md remain user-owned. Corpus choices govern future activated sessions.")
577
+ step = 0
578
+ applying = False
579
+ while True:
580
+ try:
581
+ if step == 0:
582
+ write("\nDependencies (probes do not install or sign in):")
583
+ available_actions = []
584
+ for row in dependencies:
585
+ write(f" {row['title']} — {row['status']} [{row['role']}] {row.get('version', '')}")
586
+ write(" " + row["purpose"])
587
+ if row.get("install_argv"):
588
+ available_actions.append(row)
589
+ write(f" Install {len(available_actions)}: {shlex.join(row['install_argv'])} ({row['install_scope']})")
590
+ elif row.get("manual_reason"):
591
+ write(" " + row["manual_reason"])
592
+ selected = _indexes(ask("Install numbers, or none [none]: "), len(available_actions))
593
+ plan["dependencies"] = [available_actions[index]["id"] for index in selected]
594
+ step = 1
595
+ elif step == 1:
596
+ write("\nCorpus: 1 no active corpus; 2 all available corpus; 3 selected packages/domains; 4 keep saved/default selection")
597
+ value = ask("Corpus choice [1]: ") or "1"
598
+ if value not in {"1", "2", "3", "4"}:
599
+ raise SetupError("Choose 1, 2, 3, or 4.")
600
+ plan["selection_mode"] = {"1": "none", "2": "selected", "3": "selected", "4": None}[value]
601
+ plan["targets"] = ["all"] if value == "2" else (None if value == "4" else [])
602
+ step = 2 if value == "3" else 3
603
+ elif step == 2:
604
+ for index, choice in enumerate(choices, 1):
605
+ write(f" {index}. {choice['label']}")
606
+ selected = _indexes(ask("Corpus numbers: "), len(choices))
607
+ if not selected:
608
+ raise SetupError("Select at least one corpus, or go back to choose no active corpus.")
609
+ plan["targets"] = [choices[index]["target"] for index in selected]
610
+ step = 3
611
+ elif step == 3:
612
+ write("\nCodex app bridge supports per-task corpus preview/use/off and corpus management through $agent-bios.")
613
+ write("The TUI runs in a terminal or the Codex app terminal panel.")
614
+ value = ask("Register the Codex app bridge? [y/N]: ").lower()
615
+ if value not in {"", "n", "no", "y", "yes"}:
616
+ raise SetupError("Enter yes or no.")
617
+ plan["app_bridge"] = value in {"y", "yes"}
618
+ step = 4
619
+ elif step == 4:
620
+ write("\nPrepare existing instructions for personal corpus review. Source files are preserved.")
621
+ write("Enter explicit project directories as JSON, for example [\"/path/to/project\"].")
622
+ raw = ask("Project directories [skip; globals = global sources only]: ")
623
+ if raw.lower() in {"", "skip", "none"}:
624
+ plan["project_roots"], plan["import_paths"] = [], []
625
+ step = 6
626
+ continue
627
+ try:
628
+ roots = [] if raw.lower() == "globals" else json.loads(raw)
629
+ except ValueError as exc:
630
+ raise SetupError("Enter a JSON list of absolute project directories, globals, or skip.") from exc
631
+ if not isinstance(roots, list) or not all(isinstance(root, str) and Path(root).is_absolute() and Path(root).is_dir() for root in roots):
632
+ raise SetupError("Project roots must be existing absolute directories.")
633
+ plan["project_roots"] = list(dict.fromkeys(roots))
634
+ discovery = getattr(installer, "setup_discover", None)
635
+ if not callable(discovery):
636
+ raise SetupError("Instruction discovery is unavailable in this installer.")
637
+ found = discovery(plan["project_roots"])
638
+ candidates = found.get("sources", []) if isinstance(found, dict) else found
639
+ candidates = [dict(row) if isinstance(row, dict) else {"path": str(row)} for row in candidates]
640
+ if not candidates:
641
+ write("No eligible instruction files were found in those locations.")
642
+ plan["import_paths"] = []
643
+ step = 6
644
+ continue
645
+ step = 5
646
+ elif step == 5:
647
+ for index, row in enumerate(candidates, 1):
648
+ write(f" {index}. {row['path']}")
649
+ selected = _indexes(ask("Instruction file numbers to capture, or none [none]: "), len(candidates))
650
+ plan["import_paths"] = [str(candidates[index]["path"]) for index in selected]
651
+ step = 6
652
+ else:
653
+ preview = controller.preview(plan)
654
+ write("\nReview installation plan:")
655
+ write(json.dumps(preview, ensure_ascii=False, indent=2))
656
+ if plan["import_paths"]:
657
+ write("Captured instructions require model review before choosing consumption surfaces. Capture alone does not activate them.")
658
+ if dry_run:
659
+ return {"applied": False, "dry_run": True, **preview}
660
+ value = ask("Type apply to execute this plan, back to revise, or cancel: ").lower()
661
+ if value != "apply":
662
+ raise SetupError("Nothing was applied. Type apply, back, or cancel.")
663
+ applying = True
664
+ return controller.apply(plan, preview=preview, progress=lambda event: write(event["message"]))
665
+ except _Back:
666
+ step = {0: 0, 1: 0, 2: 1, 3: 2 if plan["selection_mode"] == "selected" and plan["targets"] != ["all"] else 1,
667
+ 4: 3, 5: 4, 6: 4}.get(step, 4)
668
+ except _Cancel:
669
+ write("Setup cancelled; no installation plan was applied.")
670
+ return {"cancelled": True, "applied": False}
671
+ except SetupError as exc:
672
+ if applying:
673
+ raise
674
+ write(str(exc))