agent-bios 0.19.1 → 0.19.3

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