nobrainer-tech-flow 0.0.0-stage → 2.4.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 (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.claude-plugin/plugin.json +21 -0
  4. package/.codex-plugin/plugin.json +47 -0
  5. package/.opencode/INSTALL.md +37 -0
  6. package/.opencode/plugins/nobrainer-tech-skills.js +66 -0
  7. package/.pi/extensions/nobrainer-tech-skills.js +64 -0
  8. package/AGENTS.md +204 -0
  9. package/CLAUDE.md +204 -0
  10. package/CONTRIBUTING.md +70 -0
  11. package/GEMINI.md +1 -0
  12. package/LICENSE +21 -0
  13. package/README.md +658 -2
  14. package/SECURITY.md +33 -0
  15. package/adapters/bootstrap.md +27 -0
  16. package/assets/nobrainer-tech-logo.svg +1 -0
  17. package/assets/nobrainer-workflow.svg +155 -0
  18. package/assets/plugin-logo.png +0 -0
  19. package/bin/nobrainer-tech-flow.mjs +48 -0
  20. package/docs/BOUNDED_RUNNER.md +60 -0
  21. package/docs/CHAT_PLUGINS.md +29 -0
  22. package/docs/COMPATIBILITY.md +371 -0
  23. package/docs/INSTALL.md +626 -0
  24. package/docs/MIGRATION_TO_FLOW.md +117 -0
  25. package/docs/NAMING.md +39 -0
  26. package/docs/PRIVACY.md +9 -0
  27. package/docs/SESSION_RESTART.md +70 -0
  28. package/docs/SKILL_CURATION.md +124 -0
  29. package/docs/TESTING.md +232 -0
  30. package/docs/TRY_IT.md +109 -0
  31. package/docs/releases/v1.0.0.md +81 -0
  32. package/docs/releases/v1.1.0.md +85 -0
  33. package/docs/releases/v1.10.0.md +34 -0
  34. package/docs/releases/v1.11.0-publication-readback.md +25 -0
  35. package/docs/releases/v1.11.0.md +42 -0
  36. package/docs/releases/v1.12.0.md +30 -0
  37. package/docs/releases/v1.13.0.md +46 -0
  38. package/docs/releases/v1.14.0.md +43 -0
  39. package/docs/releases/v1.14.1.md +21 -0
  40. package/docs/releases/v1.2.0.md +87 -0
  41. package/docs/releases/v1.2.1.md +92 -0
  42. package/docs/releases/v1.3.0-publication-readback.md +69 -0
  43. package/docs/releases/v1.3.0.md +66 -0
  44. package/docs/releases/v1.3.1-publication-readback.md +76 -0
  45. package/docs/releases/v1.3.1.md +59 -0
  46. package/docs/releases/v1.4.0-publication-readback.md +81 -0
  47. package/docs/releases/v1.4.0.md +43 -0
  48. package/docs/releases/v1.5.0-publication-readback.md +76 -0
  49. package/docs/releases/v1.5.0.md +54 -0
  50. package/docs/releases/v1.6.0.md +64 -0
  51. package/docs/releases/v1.6.1-publication-readback.md +53 -0
  52. package/docs/releases/v1.6.1.md +82 -0
  53. package/docs/releases/v1.7.0.md +42 -0
  54. package/docs/releases/v1.7.1-publication-readback.md +64 -0
  55. package/docs/releases/v1.7.1.md +49 -0
  56. package/docs/releases/v1.8.0.md +63 -0
  57. package/docs/releases/v1.8.1.md +32 -0
  58. package/docs/releases/v1.9.0.md +33 -0
  59. package/docs/releases/v2.0.0-candidate.md +28 -0
  60. package/docs/releases/v2.0.0.md +53 -0
  61. package/docs/releases/v2.0.1.md +135 -0
  62. package/docs/releases/v2.0.2.md +189 -0
  63. package/docs/releases/v2.1.0.md +64 -0
  64. package/docs/releases/v2.1.1.md +43 -0
  65. package/docs/releases/v2.2.0.md +64 -0
  66. package/docs/releases/v2.2.1.md +11 -0
  67. package/docs/releases/v2.3.0.md +97 -0
  68. package/docs/releases/v2.3.1.md +71 -0
  69. package/docs/releases/v2.3.2.md +242 -0
  70. package/docs/releases/v2.4.0.md +126 -0
  71. package/gemini-extension.json +6 -0
  72. package/hooks/claude-hooks.json +17 -0
  73. package/hooks/hooks-cursor.json +10 -0
  74. package/hooks/run-hook.cmd +36 -0
  75. package/hooks/session-start +38 -0
  76. package/package.json +69 -4
  77. package/plugin.json +51 -0
  78. package/scripts/build_plugin.py +50 -0
  79. package/scripts/check_flow_update.py +18 -0
  80. package/scripts/check_public_commit.py +86 -0
  81. package/scripts/fetch_public_refs.py +69 -0
  82. package/scripts/install.py +600 -0
  83. package/scripts/install.sh +1392 -0
  84. package/scripts/install_guards.py +279 -0
  85. package/scripts/install_personalization.py +527 -0
  86. package/scripts/install_skills.py +951 -0
  87. package/scripts/npm_install.py +85 -0
  88. package/scripts/recommend_flow_setup.py +1522 -0
  89. package/scripts/validate_skills.py +465 -0
  90. package/skills/nobrainer-auto-fine-tune/SKILL.md +79 -0
  91. package/skills/nobrainer-auto-fine-tune/references/capacity-audit.md +49 -0
  92. package/skills/nobrainer-autoimprove/SKILL.md +212 -0
  93. package/skills/nobrainer-autoimprove/scripts/count_words.py +22 -0
  94. package/skills/nobrainer-browser/SKILL.md +234 -0
  95. package/skills/nobrainer-browser/references/cdp-profile-restart.md +93 -0
  96. package/skills/nobrainer-build/SKILL.md +173 -0
  97. package/skills/nobrainer-codex-context/SKILL.md +111 -0
  98. package/skills/nobrainer-codex-context/scripts/codex_context.py +175 -0
  99. package/skills/nobrainer-decide/SKILL.md +198 -0
  100. package/skills/nobrainer-dispatcher/SKILL.md +256 -0
  101. package/skills/nobrainer-dispatcher/agents/openai.yaml +4 -0
  102. package/skills/nobrainer-rca/SKILL.md +163 -0
  103. package/skills/nobrainer-research/SKILL.md +131 -0
  104. package/skills/nobrainer-review/SKILL.md +195 -0
  105. package/skills/nobrainer-security/SKILL.md +166 -0
  106. package/skills/nobrainer-sessions/SKILL.md +223 -0
  107. package/skills/nobrainer-sessions/references/evidence-receipt.md +55 -0
  108. package/skills/nobrainer-sessions/references/protocol.md +337 -0
  109. package/skills/nobrainer-sessions/references/session-restart.md +255 -0
  110. package/skills/nobrainer-sessions/scripts/evidence_receipt.py +373 -0
  111. package/skills/nobrainer-sessions/scripts/restart_gate.py +140 -0
  112. package/skills/nobrainer-sessions/scripts/run_bounded.py +390 -0
  113. package/skills/nobrainer-sessions/scripts/session_title.py +43 -0
  114. package/skills/nobrainer-skill-doctor/SKILL.md +149 -0
  115. package/skills/nobrainer-skill-doctor/references/astra.md +19 -0
  116. package/skills/nobrainer-skill-doctor/scripts/audit.py +100 -0
  117. package/skills/nobrainer-skill-doctor/scripts/context_candidates.py +133 -0
  118. package/skills/nobrainer-spec-driven-development/SKILL.md +132 -0
  119. package/skills/nobrainer-spec-driven-development/references/spec-template.md +114 -0
  120. package/skills/nobrainer-team/SKILL.md +165 -0
  121. package/skills/nobrainer-team/references/team-plan.md +64 -0
  122. package/skills/nobrainer-tech-flow/SKILL.md +239 -0
  123. package/skills/nobrainer-tech-flow/VERSION +1 -0
  124. package/skills/nobrainer-tech-flow/references/autopilot.md +72 -0
  125. package/skills/nobrainer-tech-flow/references/codex-routing.md +21 -0
  126. package/skills/nobrainer-tech-flow/references/communication.md +108 -0
  127. package/skills/nobrainer-tech-flow/references/correction-hooks.md +110 -0
  128. package/skills/nobrainer-tech-flow/references/daily-update.md +28 -0
  129. package/skills/nobrainer-tech-flow/references/delivery.md +243 -0
  130. package/skills/nobrainer-tech-flow/references/idea-to-delivery.md +82 -0
  131. package/skills/nobrainer-tech-flow/references/long-run-state.md +209 -0
  132. package/skills/nobrainer-tech-flow/references/model-routing.md +83 -0
  133. package/skills/nobrainer-tech-flow/references/optional-decisions.md +83 -0
  134. package/skills/nobrainer-tech-flow/references/project-start.md +22 -0
  135. package/skills/nobrainer-tech-flow/references/routing.md +125 -0
  136. package/skills/nobrainer-tech-flow/references/setup.md +180 -0
  137. package/skills/nobrainer-tech-flow/references/worktrees.md +138 -0
  138. package/skills/nobrainer-tech-flow/scripts/check_flow_update.py +298 -0
  139. package/skills/nobrainer-tech-flow/scripts/cleanup_worktree.py +327 -0
  140. package/skills/nobrainer-tech-flow/scripts/decision_config.py +107 -0
  141. package/skills/nobrainer-tech-flow/scripts/flow_guard.py +252 -0
  142. package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +210 -0
  143. package/skills/nobrainer-wiki/SKILL.md +164 -0
  144. package/skills/nobrainer-wiki/references/setup.md +88 -0
  145. package/skills/nobrainer-writing/SKILL.md +272 -0
  146. package/skills/nobrainer-writing/agents/openai.yaml +4 -0
  147. package/skills/nobrainer-writing/references/brief-artifacts.md +357 -0
  148. package/skills/nobrainer-writing/references/plain-technical-english.md +76 -0
  149. package/skills/nobrainer-writing/references/research.md +86 -0
@@ -0,0 +1,1522 @@
1
+ #!/usr/bin/env python3
2
+ """Guide a read-only nobrainer-tech-flow setup preflight and install an approved subset."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import base64
8
+ import hashlib
9
+ import importlib.util
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import stat
16
+ import sys
17
+ import tempfile
18
+ import unicodedata
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from urllib.error import HTTPError, URLError
22
+ from urllib.parse import quote, urljoin, urlparse
23
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
24
+
25
+ try:
26
+ import tomllib
27
+ except ModuleNotFoundError: # Python 3.10 can still inspect JSON-based clients.
28
+ tomllib = None # type: ignore[assignment]
29
+
30
+
31
+ ROOT = Path(__file__).resolve().parents[1]
32
+ SKILLS_DIR = ROOT / "skills"
33
+ PERSONALIZATION = ROOT / "scripts" / "install_personalization.py"
34
+ INSTALL_SKILLS = ROOT / "scripts" / "install_skills.py"
35
+ STATE_NAME = ".nobrainer-flow-onboarding.json"
36
+ CLIENTS = ("codex", "claude", "opencode", "copilot")
37
+
38
+
39
+ class InputRequired(ValueError):
40
+ """The guided flow needs answers that were not supplied and cannot be asked."""
41
+
42
+
43
+ _SIBLINGS: dict[Path, object] = {}
44
+
45
+
46
+ def _sibling(path: Path):
47
+ """Load a sibling script as a module, so client path rules have one source."""
48
+
49
+ if path not in _SIBLINGS:
50
+ spec = importlib.util.spec_from_file_location(f"_flow_{path.stem}", path)
51
+ if spec is None or spec.loader is None:
52
+ raise ValueError(f"cannot load {path}")
53
+ module = importlib.util.module_from_spec(spec)
54
+ # dataclasses resolve string annotations through sys.modules.
55
+ sys.modules[spec.name] = module
56
+ try:
57
+ spec.loader.exec_module(module)
58
+ except BaseException:
59
+ sys.modules.pop(spec.name, None)
60
+ raise
61
+ _SIBLINGS[path] = module
62
+ return _SIBLINGS[path]
63
+
64
+
65
+ def skills_destination(client: str, home: Path, environ) -> Path:
66
+ return _sibling(INSTALL_SKILLS).client_destinations(environ, home, client)[client]
67
+
68
+
69
+ def instruction_file(client: str, home: Path, environ) -> Path:
70
+ return _sibling(PERSONALIZATION).known_client_path(client, home, environ).path
71
+
72
+
73
+ CLIENT_VARIABLES = {"claude": "CLAUDE_CONFIG_DIR", "codex": "CODEX_HOME", "opencode": "XDG_CONFIG_HOME"}
74
+
75
+
76
+ def recorded_setting(client: str, home: Path, destination: object, target: object) -> str | None:
77
+ """The variable setting under which a setup lands where its record says it went,
78
+ as "NAME=value" or "NAME unset", or None when no one setting explains the record.
79
+
80
+ The variable, not the record, decides where an undo may write, so this is what a
81
+ person must set to undo a setup made with another value.
82
+ """
83
+
84
+ name = CLIENT_VARIABLES.get(client)
85
+ if name is None:
86
+ return None
87
+ if client == "codex":
88
+ if not isinstance(target, str) or not target:
89
+ return None
90
+ base = Path(target).parent
91
+ else:
92
+ if not isinstance(destination, str) or not destination:
93
+ return None
94
+ base = Path(destination).parent if client == "claude" else Path(destination).parent.parent
95
+ for environ, setting in (({}, f"{name} unset"), ({name: str(base)}, f"{name}={base}")):
96
+ try:
97
+ if client != "codex" and (
98
+ skills_destination(client, home, environ).expanduser().resolve()
99
+ != Path(str(destination)).expanduser().resolve()
100
+ ):
101
+ continue
102
+ if isinstance(target, str) and target and instruction_file(client, home, environ) != Path(target):
103
+ continue
104
+ except (OSError, ValueError):
105
+ continue
106
+ return setting
107
+ return None
108
+
109
+
110
+ def links_to(target: Path, source: Path) -> bool:
111
+ """True when target is a symlink resolving to source; loops and dangling links are not."""
112
+
113
+ try:
114
+ return target.is_symlink() and target.resolve(strict=True) == source.resolve(strict=True)
115
+ except (OSError, RuntimeError):
116
+ return False
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Item:
121
+ id: str
122
+ skill: str
123
+ label: str
124
+ profiles: tuple[str, ...]
125
+ reason: str
126
+
127
+
128
+ # IDs are a user-facing selection contract. Keep them stable when descriptions
129
+ # or the repository's directory ordering changes; retired IDs must not be reused.
130
+ ITEMS = (
131
+ Item("01", "nobrainer-tech-flow", "Task orchestration", ("all",), "one task entrypoint and completion checks"),
132
+ Item("02", "nobrainer-auto-fine-tune", "Capability audit", ("all",), "read-only client/model/context/delegation audit; preserve MAIN"),
133
+ Item("03", "nobrainer-build", "Implementation", ("software-development", "operations"), "bounded code and configuration changes with verification"),
134
+ Item("04", "nobrainer-review", "Code review", ("software-development",), "independent checks for defects, security and maintainability"),
135
+ Item("05", "nobrainer-security", "Security review", ("software-development", "operations"), "trust-boundary and security analysis"),
136
+ Item("06", "nobrainer-research", "Research", ("research", "product"), "source-backed answers for uncertain or changing questions"),
137
+ Item("07", "nobrainer-writing", "Writing", ("writing", "product", "research"), "clear, fact-preserving user-facing and technical prose"),
138
+ Item("08", "nobrainer-browser", "Browser work", ("software-development", "operations"), "inspect and verify rendered web behavior"),
139
+ Item("09", "nobrainer-decide", "Decision support", ("product", "research", "operations"), "compare real options against stated constraints"),
140
+ Item("10", "nobrainer-dispatcher", "Work queue", ("software-development", "operations"), "order ready independent units and manage backpressure"),
141
+ Item("11", "nobrainer-sessions", "Session continuity", ("software-development", "operations", "research"), "checkpoint, hand off and verify task continuity"),
142
+ Item("12", "nobrainer-skill-doctor", "Skill quality", ("software-development",), "audit skill structure and practical behavior"),
143
+ Item("13", "nobrainer-autoimprove", "Measured improvement", ("software-development", "product"), "run bounded baseline/candidate/holdout improvement work"),
144
+ Item("14", "nobrainer-spec-driven-development", "Specifications", ("software-development", "product"), "define durable contracts for architectural changes"),
145
+ Item("15", "nobrainer-rca", "Root-cause analysis", ("software-development", "operations"), "diagnose repeated or causally unclear failures"),
146
+ Item("16", "nobrainer-team", "Capability-to-role design", ("software-development", "operations"), "assign available capabilities to bounded work"),
147
+ Item("17", "nobrainer-codex-context", "Project context", ("software-development", "operations"), "inspect and maintain project instructions and context"),
148
+ Item("18", "nobrainer-wiki", "Durable knowledge", ("research", "product", "operations"), "query and maintain a sourced project knowledge base"),
149
+ )
150
+ BY_ID = {item.id: item for item in ITEMS}
151
+ BY_SKILL = {item.skill: item for item in ITEMS}
152
+ PROFILE_CHOICES = ("software-development", "research", "writing", "operations", "product")
153
+ MAX_LOCAL_FILE_BYTES = 64 * 1024
154
+ MAX_API_RESPONSE_BYTES = 1_200_000
155
+
156
+
157
+ def safe_repo_url(value: str) -> str:
158
+ parsed = urlparse(value.strip())
159
+ if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
160
+ raise ValueError("repo URL must be an HTTPS URL without embedded credentials")
161
+ if not re.fullmatch(r"[A-Za-z0-9.-]+", parsed.hostname or ""):
162
+ raise ValueError("repo URL host is invalid")
163
+ return value.strip().rstrip("/")
164
+
165
+
166
+ def display_repo_url(value: str) -> str:
167
+ parsed = urlparse(value)
168
+ return f"{parsed.scheme}://{parsed.hostname}{parsed.path.rstrip('/')}"
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class RepositoryContext:
173
+ status: str
174
+ signals: str = ""
175
+ files: tuple[str, ...] = ()
176
+ summary: str = ""
177
+ existing_skills: tuple[str, ...] = ()
178
+
179
+
180
+ def _read_local_text(path: Path) -> str | None:
181
+ """Read a small regular source file without following repo-owned symlinks."""
182
+
183
+ for parent in reversed(path.parents[:-1]):
184
+ try:
185
+ if stat.S_ISLNK(parent.lstat().st_mode):
186
+ return None
187
+ except OSError:
188
+ return None
189
+ descriptor = -1
190
+ try:
191
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
192
+ metadata = os.fstat(descriptor)
193
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_LOCAL_FILE_BYTES:
194
+ return None
195
+ with os.fdopen(descriptor, "rb") as handle:
196
+ descriptor = -1
197
+ return handle.read(MAX_LOCAL_FILE_BYTES + 1).decode("utf-8", errors="replace")
198
+ except OSError:
199
+ return None
200
+ finally:
201
+ if descriptor >= 0:
202
+ os.close(descriptor)
203
+
204
+
205
+ REPO_TEXT_FILES = (
206
+ "README.md",
207
+ "AGENTS.md",
208
+ "CLAUDE.md",
209
+ "GEMINI.md",
210
+ ".github/copilot-instructions.md",
211
+ "pyproject.toml",
212
+ "package.json",
213
+ "Cargo.toml",
214
+ "go.mod",
215
+ "requirements.txt",
216
+ )
217
+ SKILL_DIRS = (".agents/skills", ".claude/skills", ".codex/skills", ".opencode/skills")
218
+
219
+
220
+ def inspect_local_repository(root: Path) -> RepositoryContext:
221
+ root = root.expanduser().resolve(strict=True)
222
+ if not root.is_dir():
223
+ raise ValueError(f"local repository path is not a directory: {root}")
224
+ try:
225
+ git_marker = (root / ".git").lstat()
226
+ except OSError as exc:
227
+ raise ValueError(f"local path is not a Git checkout: {root}") from exc
228
+ if stat.S_ISLNK(git_marker.st_mode) or not (
229
+ stat.S_ISDIR(git_marker.st_mode) or stat.S_ISREG(git_marker.st_mode)
230
+ ):
231
+ raise ValueError(f"local Git metadata path is not a regular checkout marker: {root}")
232
+ documents: dict[str, str] = {}
233
+ for relative in REPO_TEXT_FILES:
234
+ value = _read_local_text(root / relative)
235
+ if value is not None:
236
+ documents[relative] = value
237
+ skill_names: set[str] = set()
238
+ for relative in SKILL_DIRS:
239
+ folder = root / relative
240
+ try:
241
+ metadata = folder.lstat()
242
+ except OSError:
243
+ continue
244
+ if not folder.is_dir() or folder.is_symlink():
245
+ continue
246
+ for child in folder.iterdir():
247
+ if child.name in BY_SKILL and child.is_dir():
248
+ skill_names.add(child.name)
249
+ texts = "\n".join(documents.values())
250
+ mention = {item.skill for item in ITEMS if re.search(rf"(?<![A-Za-z0-9_-]){re.escape(item.skill)}(?![A-Za-z0-9_-])", texts)}
251
+ file_list = tuple(documents)
252
+ summary = f"local files read={','.join(file_list) or 'none'}; project skill directories={len(skill_names)}"
253
+ return RepositoryContext(
254
+ "LOCAL_READ_ONLY",
255
+ signals=texts,
256
+ files=file_list,
257
+ summary=summary,
258
+ existing_skills=tuple(sorted(skill_names | mention)),
259
+ )
260
+
261
+
262
+ def _safe_label(value: str, limit: int = 100) -> str:
263
+ cleaned = "".join(char if char.isprintable() and char not in "`<>" else " " for char in value)
264
+ return " ".join(cleaned.split())[:limit]
265
+
266
+
267
+ def github_repo_parts(repo_url: str) -> tuple[str, str] | None:
268
+ parsed = urlparse(repo_url)
269
+ if (parsed.hostname or "").lower() not in {"github.com", "www.github.com"}:
270
+ return None
271
+ parts = [part for part in parsed.path.split("/") if part]
272
+ if len(parts) < 2:
273
+ raise ValueError("GitHub repository link must include owner and repository")
274
+ owner, repository = parts[:2]
275
+ repository = repository.removesuffix(".git")
276
+ valid = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")
277
+ if not valid.fullmatch(owner) or not valid.fullmatch(repository) or owner in {".", ".."} or repository in {".", ".."}:
278
+ raise ValueError("GitHub owner or repository name is invalid")
279
+ return owner, repository
280
+
281
+
282
+ class _GitHubRedirectHandler(HTTPRedirectHandler):
283
+ def redirect_request(self, request, file_pointer, code, message, headers, new_url):
284
+ target = urlparse(urljoin(request.full_url, new_url))
285
+ if target.scheme != "https" or target.hostname != "api.github.com":
286
+ return None
287
+ return super().redirect_request(request, file_pointer, code, message, headers, new_url)
288
+
289
+
290
+ def _open_github_api(request: Request, timeout: int):
291
+ return build_opener(_GitHubRedirectHandler()).open(request, timeout=timeout)
292
+
293
+
294
+ def _github_json(endpoint: str, timeout: int = 8) -> tuple[dict[str, object] | None, str]:
295
+ """Read a bounded GitHub REST response, preferring an existing gh login."""
296
+
297
+ timeout = min(timeout, 4)
298
+ gh = shutil.which("gh")
299
+ if gh:
300
+ try:
301
+ result = subprocess.run(
302
+ [gh, "api", "--hostname", "github.com", endpoint],
303
+ cwd=ROOT,
304
+ # GitHub JSON is UTF-8; the Windows code page cannot decode all of it.
305
+ encoding="utf-8",
306
+ errors="replace",
307
+ capture_output=True,
308
+ timeout=timeout,
309
+ check=False,
310
+ )
311
+ if result.returncode == 0 and len(result.stdout.encode("utf-8")) <= MAX_API_RESPONSE_BYTES:
312
+ value = json.loads(result.stdout)
313
+ if isinstance(value, dict):
314
+ return value, "GITHUB_READ_ONLY_GH"
315
+ if result.returncode == 0:
316
+ return None, "RESPONSE_TOO_LARGE"
317
+ except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError):
318
+ pass
319
+
320
+ request = Request(
321
+ f"https://api.github.com/{endpoint}",
322
+ headers={
323
+ "Accept": "application/vnd.github+json",
324
+ "User-Agent": "nobrainer-tech-flow-setup-preflight",
325
+ "X-GitHub-Api-Version": "2022-11-28",
326
+ },
327
+ method="GET",
328
+ )
329
+ try:
330
+ with _open_github_api(request, timeout=timeout) as response:
331
+ raw = response.read(MAX_API_RESPONSE_BYTES + 1)
332
+ if len(raw) > MAX_API_RESPONSE_BYTES:
333
+ return None, "RESPONSE_TOO_LARGE"
334
+ value = json.loads(raw)
335
+ if isinstance(value, dict):
336
+ return value, "GITHUB_READ_ONLY_API"
337
+ return None, "INVALID_RESPONSE"
338
+ except HTTPError as exc:
339
+ return None, f"HTTP_{exc.code}"
340
+ except (OSError, URLError, TimeoutError, json.JSONDecodeError):
341
+ return None, "NETWORK_UNAVAILABLE"
342
+
343
+
344
+ def inspect_github_repository(repo_url: str) -> RepositoryContext:
345
+ parts = github_repo_parts(repo_url)
346
+ if parts is None:
347
+ return RepositoryContext("UNSUPPORTED_HOST")
348
+ owner, repository = parts
349
+ owner_path, repo_path = quote(owner, safe=""), quote(repository, safe="")
350
+ metadata, source = _github_json(f"repos/{owner_path}/{repo_path}")
351
+ if metadata is None:
352
+ return RepositoryContext(source)
353
+ readme, readme_source = _github_json(f"repos/{owner_path}/{repo_path}/readme")
354
+ text = ""
355
+ if readme is not None and readme.get("encoding") == "base64" and isinstance(readme.get("content"), str):
356
+ try:
357
+ content = base64.b64decode(readme["content"], validate=True)
358
+ if len(content) <= MAX_LOCAL_FILE_BYTES:
359
+ text = content.decode("utf-8", errors="replace")
360
+ except (ValueError, TypeError):
361
+ text = ""
362
+ language = metadata.get("language") if isinstance(metadata.get("language"), str) else "UNKNOWN"
363
+ description = metadata.get("description") if isinstance(metadata.get("description"), str) else ""
364
+ topics = metadata.get("topics") if isinstance(metadata.get("topics"), list) else []
365
+ safe_topics = [
366
+ _safe_label(topic, 40)
367
+ for topic in topics
368
+ if isinstance(topic, str) and re.fullmatch(r"[A-Za-z0-9_.-]{1,40}", topic)
369
+ ][:12]
370
+ signals = " ".join((str(language), description[:1000], " ".join(safe_topics), text))
371
+ summary = f"GitHub metadata read; language={_safe_label(str(language), 40)}; README={'read' if text else 'unavailable'}"
372
+ # Never print README text, repo instructions or descriptions: all are
373
+ # untrusted data used only as bounded keyword context for recommendations.
374
+ return RepositoryContext(source + (f"+README_{readme_source}" if readme_source else ""), signals=signals, summary=summary)
375
+
376
+
377
+ # Each client keeps its settings file next to its global instruction file.
378
+ CLIENT_CONFIG_FILES = {
379
+ "codex": "config.toml",
380
+ "claude": "settings.json",
381
+ "opencode": "opencode.json",
382
+ "copilot": "config.json",
383
+ }
384
+
385
+
386
+ def inspect_client_config(client: str, home: Path, environ=None) -> dict[str, str]:
387
+ environ = {} if environ is None else environ
388
+ path = instruction_file(client, home.expanduser(), environ).parent / CLIENT_CONFIG_FILES[client]
389
+ report = {
390
+ "config_path": str(path),
391
+ "config_status": "MISSING",
392
+ "configured_main_model": "UNKNOWN",
393
+ "configured_effort": "UNKNOWN",
394
+ "active_profile": "UNKNOWN",
395
+ "advertised_models": "UNKNOWN",
396
+ "callable_workers": "UNKNOWN",
397
+ "runtime_main_and_effort": "UNKNOWN",
398
+ "runtime_context": "UNKNOWN",
399
+ }
400
+ try:
401
+ metadata = path.lstat()
402
+ except OSError:
403
+ return report
404
+ if path.is_symlink() or not path.is_file() or metadata.st_size > MAX_LOCAL_FILE_BYTES:
405
+ report["config_status"] = "PRESENT_UNREADABLE_OR_UNSUPPORTED"
406
+ return report
407
+ try:
408
+ text = path.read_text(encoding="utf-8")
409
+ if client == "codex":
410
+ config = tomllib.loads(text)
411
+ else:
412
+ config = json.loads(text)
413
+ if not isinstance(config, dict):
414
+ report["config_status"] = "INVALID_FORMAT"
415
+ return report
416
+ model = config.get("model")
417
+ effort = config.get("model_reasoning_effort") or config.get("reasoningEffort")
418
+ if isinstance(model, str) and len(model) <= 128:
419
+ report["configured_main_model"] = _safe_label(model, 128)
420
+ if isinstance(effort, str) and len(effort) <= 64:
421
+ report["configured_effort"] = _safe_label(effort, 64)
422
+ report["config_status"] = "READ_OK_CONFIGURED_ONLY"
423
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, tomllib.TOMLDecodeError):
424
+ report["config_status"] = "PARSE_ERROR"
425
+ return report
426
+
427
+
428
+ def ask_if_missing(args: argparse.Namespace) -> None:
429
+ print("nobrainer-tech-flow provides one task entrypoint, a required read-only capability audit, and optional specialists for implementation, research, writing, operations and product work.")
430
+ questions = (
431
+ ("work_profile", "Work type (software-development/research/writing/operations/product)", PROFILE_CHOICES),
432
+ ("goal", "What outcome do you want from this setup?", None),
433
+ ("tools", "Which client and tools do you already use?", None),
434
+ ("existing_setup", "What relevant skills, instructions or workflows are already installed?", None),
435
+ )
436
+ missing = [attribute for attribute, _, _ in questions if not getattr(args, attribute)]
437
+ if missing and not stdin_is_terminal():
438
+ flags = ", ".join("--" + attribute.replace("_", "-") for attribute in missing)
439
+ raise InputRequired(f"answers are missing and no terminal is attached; pass {flags}")
440
+ for attribute, prompt, choices in questions:
441
+ value = getattr(args, attribute)
442
+ if value:
443
+ continue
444
+ while True:
445
+ try:
446
+ entered = input(f"{prompt}: ").strip()
447
+ except EOFError:
448
+ raise InputRequired(f"input ended before {attribute.replace('_', '-')} was answered") from None
449
+ if entered and (choices is None or entered in choices):
450
+ setattr(args, attribute, entered)
451
+ break
452
+ suffix = f" Choose one of: {', '.join(choices)}." if choices else " Enter a short answer."
453
+ print("Please answer the question." + suffix, file=sys.stderr)
454
+
455
+
456
+ MATCH_TERMS = {
457
+ "01": ("task", "complete", "orchestrat"),
458
+ "02": ("model", "context", "agent", "capability"),
459
+ "03": ("code", "build", "app", "software", "ship", "python"),
460
+ "04": ("review", "defect", "quality", "code"),
461
+ "05": ("security", "auth", "secret", "threat"),
462
+ "06": ("research", "compare", "evidence", "source"),
463
+ "07": ("write", "writing", "copy", "document", "content"),
464
+ "08": ("browser", "web", "ui", "render"),
465
+ "09": ("decide", "decision", "choice", "compare"),
466
+ "10": ("queue", "parallel", "batch", "dispatch"),
467
+ "11": ("session", "context", "handoff", "continuity"),
468
+ "12": ("skill", "audit", "catalog", "quality"),
469
+ "13": ("improve", "experiment", "baseline", "measure"),
470
+ "14": ("spec", "architecture", "contract", "migration"),
471
+ "15": ("root cause", "failure", "incident", "debug"),
472
+ "16": ("team", "role", "delegate", "capability"),
473
+ "17": ("project", "context", "instructions", "agents.md"),
474
+ "18": ("wiki", "knowledge", "notes", "sources"),
475
+ }
476
+
477
+
478
+ def recommendations(
479
+ profile: str,
480
+ goal: str,
481
+ tools: str,
482
+ existing: str,
483
+ present: set[str],
484
+ repository: RepositoryContext | None = None,
485
+ ) -> list[tuple[Item, int, str]]:
486
+ context = f"{goal} {tools}".casefold()
487
+ already = existing.casefold()
488
+ repo_text = repository.signals.casefold() if repository else ""
489
+ repo_existing = set(repository.existing_skills) if repository else set()
490
+ ranked: list[tuple[Item, int]] = []
491
+ for item in ITEMS:
492
+ if item.id in {"01", "02"}:
493
+ continue
494
+ profile_match = profile in item.profiles
495
+ goal_hits = [term for term in MATCH_TERMS[item.id] if term in context]
496
+ repo_hits = [term for term in MATCH_TERMS[item.id] if term in repo_text]
497
+ existing_hit = (
498
+ item.skill.casefold() in already
499
+ or item.label.casefold() in already
500
+ or item.skill in repo_existing
501
+ )
502
+ actual_present = item.id in present
503
+ score = 4 + (3 if profile_match else 0) + (2 if goal_hits else 0)
504
+ if any(term in tools.casefold() for term in MATCH_TERMS[item.id]):
505
+ score += 1
506
+ if repo_hits:
507
+ score += 1
508
+ if existing_hit or actual_present:
509
+ score -= 3
510
+ score = max(1, min(10, score))
511
+ if profile_match or goal_hits:
512
+ reasons = []
513
+ if profile_match:
514
+ reasons.append(f"fits {profile}")
515
+ if goal_hits:
516
+ reasons.append("task/tool match: " + ", ".join(goal_hits[:3]))
517
+ if repo_hits:
518
+ reasons.append("repository context: " + ", ".join(repo_hits[:3]))
519
+ if existing_hit or actual_present:
520
+ reasons.append("possible overlap; verify current setup before adding")
521
+ ranked.append((item, score, "; ".join(reasons)))
522
+ # The core skill and standard first-setup audit are required and shown
523
+ # separately; keep the optional recommendation list useful and short.
524
+ return sorted(ranked, key=lambda row: (-row[1], row[0].id))[:6]
525
+
526
+
527
+ def installed_ids(destination: Path) -> set[str]:
528
+ found: set[str] = set()
529
+ for item in ITEMS:
530
+ if links_to(destination / item.skill, SKILLS_DIR / item.skill):
531
+ found.add(item.id)
532
+ return found
533
+
534
+
535
+ def target_state(destination: Path, item: Item) -> str:
536
+ target = destination / item.skill
537
+ if not target.exists() and not target.is_symlink():
538
+ return "MISSING"
539
+ if links_to(target, SKILLS_DIR / item.skill):
540
+ return "CURRENT"
541
+ return "CONFLICT"
542
+
543
+
544
+ def selected_ids(raw: str | None) -> list[str]:
545
+ if not raw:
546
+ return []
547
+ chosen = [part.strip().zfill(2) for part in raw.split(",") if part.strip()]
548
+ if not chosen or len(chosen) != len(set(chosen)):
549
+ raise ValueError("selection must be a comma-separated list of unique IDs")
550
+ unknown = sorted(set(chosen) - set(BY_ID))
551
+ if unknown:
552
+ raise ValueError(f"unknown selection ID(s): {', '.join(unknown)}")
553
+ return sorted(chosen)
554
+
555
+
556
+ def require_utf8(*paths: Path) -> None:
557
+ """Refuse a path the helpers cannot print back exactly: the rollback state is read from
558
+ their output, and a mangled path would make a finished setup impossible to undo."""
559
+
560
+ for path in paths:
561
+ try:
562
+ str(path).encode("utf-8")
563
+ except UnicodeEncodeError:
564
+ raise ValueError(f"path is not valid UTF-8, so the setup cannot record it: {path!r}") from None
565
+ if any(unicodedata.category(char) in ("Cc", "Zl", "Zp") for char in str(path)):
566
+ raise ValueError(
567
+ f"path contains a line break or control character, so the setup cannot record it: {path!r}"
568
+ )
569
+
570
+
571
+ def stdin_is_terminal() -> bool:
572
+ """False when there is nobody to ask: no stdin at all, a closed one, or a pipe."""
573
+
574
+ try:
575
+ return sys.stdin is not None and sys.stdin.isatty()
576
+ except ValueError:
577
+ return False
578
+
579
+
580
+ def profile_failure_hint(output: str) -> None:
581
+ if "takes precedence over" in output:
582
+ print(
583
+ "HINT: the guided setup cannot write to AGENTS.override.md; remove or empty "
584
+ "that file, or run scripts/install_personalization.py --client codex "
585
+ "--path <file> yourself."
586
+ )
587
+
588
+
589
+ def run(command: list[str], hide: tuple[str, ...] = ()) -> tuple[int, str]:
590
+ """Run a helper and echo its output, minus lines that start with a hidden prefix."""
591
+
592
+ # The helpers write UTF-8 whatever the console code page is.
593
+ result = subprocess.run(
594
+ command, cwd=ROOT, encoding="utf-8", errors="replace", capture_output=True, check=False
595
+ )
596
+ output = result.stdout + result.stderr
597
+ shown = "".join(
598
+ line for line in output.splitlines(keepends=True) if not line.startswith(hide)
599
+ )
600
+ if shown:
601
+ print(shown, end="" if shown.endswith("\n") else "\n")
602
+ return result.returncode, output
603
+
604
+
605
+ def remove_exact_created_links(destination: Path, names: list[str]) -> None:
606
+ for name in names:
607
+ if name not in BY_SKILL:
608
+ continue
609
+ target = destination / name
610
+ if links_to(target, SKILLS_DIR / name):
611
+ target.unlink()
612
+
613
+
614
+ def physical_path(path: Path) -> Path:
615
+ """PATH with the links on the way resolved, whether or not all of it exists yet."""
616
+
617
+ try:
618
+ return path.resolve()
619
+ except (OSError, RuntimeError):
620
+ return path
621
+
622
+
623
+ def missing_directories(*paths: Path) -> list[Path]:
624
+ """The folders on the way to PATHS that do not exist yet, outermost first.
625
+
626
+ A link counts as existing: it is somebody's link, never a folder this setup made. So
627
+ does a spelling with ``..`` that resolves to an existing folder: ``profile/missing/..``
628
+ does not exist before ``missing`` is made, but it is ``profile``, which was there
629
+ already. The folders above such a spelling are still checked, as ``missing`` is.
630
+ """
631
+
632
+ missing: list[Path] = []
633
+ for path in paths:
634
+ chain: list[Path] = []
635
+ for candidate in (path, *path.parents):
636
+ if candidate.exists() or candidate.is_symlink() or physical_path(candidate).exists():
637
+ continue
638
+ chain.append(candidate)
639
+ for candidate in reversed(chain):
640
+ if candidate not in missing:
641
+ missing.append(candidate)
642
+ return missing
643
+
644
+
645
+ def recorded_directories(state: dict[str, object], record: Path) -> list[Path]:
646
+ """The folders a setup record says the setup created, or ValueError when it cannot be true.
647
+
648
+ A folder the setup made lies on the way to its skills folder, to the folder of its
649
+ instruction file or to the folder of the RECORD itself, so any other entry (a relative path, a root, a path with
650
+ ``..``, a folder elsewhere) means a damaged record, and an undo must not act on it.
651
+ A path may be spelled with or without the links on the way, so both spellings count. A
652
+ folder the setup made is listed as it really is, and a base spelled with ``..`` reaches
653
+ it on the way without lying below it, so the real form of each folder on that way counts.
654
+ """
655
+
656
+ raw = state.get("created_dirs", [])
657
+ if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
658
+ raise ValueError("created_dirs must list each folder the setup created once")
659
+ bases = [Path(str(state["destination"]))]
660
+ profile = state.get("profile")
661
+ target = profile.get("target") if isinstance(profile, dict) else None
662
+ if isinstance(target, str) and target:
663
+ bases.append(Path(target).parent)
664
+ bases.append(record.parent)
665
+ places = [form for base in bases for step in (base, *base.parents) for form in (step, physical_path(step))]
666
+ directories: list[Path] = []
667
+ seen: set[Path] = set()
668
+ for item in raw:
669
+ path = Path(item)
670
+ forms = (path, physical_path(path))
671
+ if (
672
+ not path.is_absolute()
673
+ or ".." in path.parts
674
+ or path == Path(path.anchor)
675
+ or not any(form == place or form in place.parents for form in forms for place in places)
676
+ ):
677
+ raise ValueError(f"created_dirs names a folder this setup cannot have created: {item}")
678
+ if forms[1] in seen:
679
+ raise ValueError("created_dirs must list each folder the setup created once")
680
+ seen.add(forms[1])
681
+ directories.append(path)
682
+ return directories
683
+
684
+
685
+ def is_link(path: Path) -> bool:
686
+ """A symbolic link, or on Windows any reparse point such as a directory junction.
687
+
688
+ ``is_symlink`` misses a junction before Python 3.12, and removing one with ``rmdir``
689
+ disconnects whatever uses the folder it points to, even when that folder is not empty.
690
+ """
691
+
692
+ if path.is_symlink():
693
+ return True
694
+ try:
695
+ attributes = getattr(os.lstat(path), "st_file_attributes", 0)
696
+ except OSError:
697
+ return False
698
+ return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0))
699
+
700
+
701
+ def remove_empty_directories(directories: list[Path]) -> tuple[list[Path], list[Path]]:
702
+ """Remove each of these folders that is still empty, innermost first.
703
+
704
+ Returns the folders removed and the folders kept because a folder above them is a
705
+ link now. The record lists each folder with the links on the way resolved, so such a
706
+ link means the profile was rearranged since the setup, and removing the folder would
707
+ act on another tree.
708
+ """
709
+
710
+ removed: list[Path] = []
711
+ kept: list[Path] = []
712
+ for path in sorted(directories, key=lambda item: len(item.parts), reverse=True):
713
+ try:
714
+ if is_link(path) or not path.is_dir():
715
+ continue
716
+ if any(is_link(parent) for parent in path.parents):
717
+ kept.append(path)
718
+ continue
719
+ path.rmdir()
720
+ except OSError:
721
+ continue
722
+ removed.append(path)
723
+ return removed, kept
724
+
725
+
726
+ def state_path(home: Path, override: Path | None, client: str) -> Path:
727
+ if override is not None:
728
+ return override.expanduser()
729
+ suffix = STATE_NAME.removesuffix(".json")
730
+ return (home / f"{suffix}-{client}.json").expanduser()
731
+
732
+
733
+ def legacy_state_path(home: Path, override: Path | None) -> Path | None:
734
+ return None if override is not None else home / STATE_NAME
735
+
736
+
737
+ def legacy_state_client(state: dict[str, object], home: Path) -> str | None:
738
+ """Infer the owner of a v1 single-client state without mutating it."""
739
+
740
+ destinations = {
741
+ "codex": home / ".agents" / "skills",
742
+ "claude": home / ".claude" / "skills",
743
+ "opencode": home / ".config" / "opencode" / "skills",
744
+ "copilot": home / ".copilot" / "skills",
745
+ }
746
+ destination = state.get("destination")
747
+ if isinstance(destination, str):
748
+ resolved = Path(destination).expanduser().resolve()
749
+ for client, path in destinations.items():
750
+ if resolved == path.resolve():
751
+ return client
752
+ profile = state.get("profile")
753
+ target = profile.get("target") if isinstance(profile, dict) else None
754
+ if isinstance(target, str):
755
+ targets = {
756
+ "codex": home / ".codex" / "AGENTS.md",
757
+ "claude": home / ".claude" / "CLAUDE.md",
758
+ "opencode": home / ".config" / "opencode" / "AGENTS.md",
759
+ "copilot": home / ".copilot" / "copilot-instructions.md",
760
+ }
761
+ for client, path in targets.items():
762
+ if Path(target).expanduser() == path:
763
+ return client
764
+ return None
765
+
766
+
767
+ def read_state_file(path: Path) -> dict[str, object]:
768
+ if path.is_symlink() or not path.is_file():
769
+ raise ValueError(f"setup state path is not a regular file: {path}")
770
+ loaded = json.loads(path.read_text(encoding="utf-8"))
771
+ if (
772
+ not isinstance(loaded, dict)
773
+ or not isinstance(loaded.get("destination"), str)
774
+ or not isinstance(loaded.get("created_skills"), list)
775
+ or not isinstance(loaded.get("profile"), dict)
776
+ or any(not isinstance(name, str) or name not in BY_SKILL for name in loaded.get("created_skills", []))
777
+ ):
778
+ raise ValueError(f"refusing to overwrite unrecognized setup state: {path}")
779
+ try:
780
+ recorded_directories(loaded, path)
781
+ except ValueError as exc:
782
+ raise ValueError(f"refusing to overwrite unrecognized setup state: {path} ({exc})") from None
783
+ return loaded
784
+
785
+
786
+ def save_state(path: Path, state: dict[str, object]) -> None:
787
+ path.parent.mkdir(parents=True, exist_ok=True)
788
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
789
+ temporary = Path(temporary_name)
790
+ try:
791
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
792
+ handle.write(json.dumps(state, indent=2) + "\n")
793
+ handle.flush()
794
+ os.fsync(handle.fileno())
795
+ temporary.chmod(0o600)
796
+ os.replace(temporary, path)
797
+ except Exception:
798
+ temporary.unlink(missing_ok=True)
799
+ raise
800
+
801
+
802
+ BLOCK_START = b"<!-- NOBRAINER-TECH-FLOW:START -->"
803
+ BLOCK_END = b"<!-- NOBRAINER-TECH-FLOW:END -->"
804
+
805
+
806
+ def block_span(data: bytes) -> tuple[int, int] | None:
807
+ start = data.find(BLOCK_START)
808
+ end = data.find(BLOCK_END)
809
+ if start < 0 or end < start:
810
+ return None
811
+ return start, end + len(BLOCK_END)
812
+
813
+
814
+ def _fills_line(data: bytes, start: int, end: int) -> bool:
815
+ """Whether data[start:end] is a whole line, a CR before its line break allowed."""
816
+
817
+ if start and data[start - 1 : start] != b"\n":
818
+ return False
819
+ after = data[end : end + 2]
820
+ return after in (b"", b"\r", b"\r\n") or after.startswith(b"\n")
821
+
822
+
823
+ def block_lines(data: bytes) -> tuple[int, int] | None:
824
+ """The managed block as whole lines, the END line's line break included, when
825
+ each marker stands on a line of its own, as scripts/install.sh requires."""
826
+
827
+ span = block_span(data)
828
+ if span is None:
829
+ return None
830
+ start, end = span
831
+ if not (
832
+ _fills_line(data, start, start + len(BLOCK_START))
833
+ and _fills_line(data, end - len(BLOCK_END), end)
834
+ ):
835
+ return None
836
+ line_break = data.find(b"\n", end)
837
+ return start, len(data) if line_break < 0 else line_break + 1
838
+
839
+
840
+ def restored_content(current: bytes, original: bytes | None) -> bytes:
841
+ """The instruction file with its managed block put back as it was before the setup.
842
+
843
+ Everything outside the block stays as it is now, so edits made since the setup
844
+ survive. When nothing outside the block changed, the result is the original file
845
+ byte for byte: the setup added one line break before the block when the file did
846
+ not end with one, and that goes too. A block on lines of its own is replaced as
847
+ whole lines, as scripts/install.sh does, so both undo to the same bytes.
848
+ """
849
+
850
+ whole = block_lines(current)
851
+ span = whole or block_span(current)
852
+ if span is None:
853
+ raise ValueError("managed block not found")
854
+ before = b""
855
+ if original is not None:
856
+ original_span = (block_lines(original) if whole else None) or block_span(original)
857
+ if original_span is not None:
858
+ before = original[original_span[0] : original_span[1]]
859
+ after = current[span[1] :]
860
+ if whole and before and not before.endswith(b"\n") and after:
861
+ # The block ended the original file; the text now after it keeps its own line.
862
+ before += b"\n"
863
+ restored = current[: span[0]] + before + after
864
+ if original is not None and restored == original + b"\n":
865
+ return original
866
+ return restored
867
+
868
+
869
+ def save_preimage(path: Path, preimage: tuple[bytes, int]) -> Path:
870
+ """Keep the file as it was before the setup next to it, as the helper's backups are."""
871
+
872
+ backup = _sibling(PERSONALIZATION).backup_path(path)
873
+ data, mode = preimage
874
+ with backup.open("xb") as handle:
875
+ handle.write(data)
876
+ os.chmod(backup, mode)
877
+ return backup
878
+
879
+
880
+ def read_file_preimage(path: Path) -> tuple[bytes, int] | None:
881
+ """Capture an exact regular-file preimage for a bounded rollback."""
882
+ try:
883
+ metadata = path.lstat()
884
+ except FileNotFoundError:
885
+ return None
886
+ if not stat.S_ISREG(metadata.st_mode):
887
+ raise ValueError(f"rollback target is not a regular file: {path}")
888
+ return path.read_bytes(), stat.S_IMODE(metadata.st_mode)
889
+
890
+
891
+ def restore_file_preimage(
892
+ path: Path,
893
+ expected_sha256: str,
894
+ preimage: tuple[bytes, int] | None,
895
+ ) -> bool:
896
+ """Restore only a file still matching this invocation's write."""
897
+ try:
898
+ metadata = path.lstat()
899
+ except FileNotFoundError:
900
+ return preimage is None
901
+ if not stat.S_ISREG(metadata.st_mode):
902
+ return False
903
+ current = path.read_bytes()
904
+ if preimage is not None and current == preimage[0] and stat.S_IMODE(metadata.st_mode) == preimage[1]:
905
+ return True
906
+ if hashlib.sha256(current).hexdigest() != expected_sha256:
907
+ return False
908
+ if preimage is None:
909
+ latest = path.lstat()
910
+ if (latest.st_dev, latest.st_ino) != (metadata.st_dev, metadata.st_ino) or path.read_bytes() != current:
911
+ return False
912
+ path.unlink()
913
+ return not path.exists() and not path.is_symlink()
914
+
915
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.rollback.", dir=path.parent)
916
+ temporary = Path(temporary_name)
917
+ try:
918
+ with os.fdopen(descriptor, "wb") as handle:
919
+ handle.write(preimage[0])
920
+ handle.flush()
921
+ os.fsync(handle.fileno())
922
+ temporary.chmod(preimage[1])
923
+ latest = path.lstat()
924
+ if (latest.st_dev, latest.st_ino) != (metadata.st_dev, metadata.st_ino) or path.read_bytes() != current:
925
+ return False
926
+ os.replace(temporary, path)
927
+ finally:
928
+ temporary.unlink(missing_ok=True)
929
+ restored = path.lstat()
930
+ return (
931
+ stat.S_ISREG(restored.st_mode)
932
+ and path.read_bytes() == preimage[0]
933
+ and stat.S_IMODE(restored.st_mode) == preimage[1]
934
+ )
935
+
936
+
937
+ def print_changed_profile(target: Path, backup: object) -> None:
938
+ print(f"PRESERVED: personalization changed since setup: {target}; it is left exactly as it is, managed block included")
939
+ by_hand = f"delete the lines from {BLOCK_START.decode()} to {BLOCK_END.decode()} yourself and undo again"
940
+ # Running the setup again keeps the backup the record names, and the undo after it puts
941
+ # the block back from that backup. With the backup gone or unreadable that undo would stop,
942
+ # so only the way by hand is offered.
943
+ backup_file = Path(str(backup)) if backup else None
944
+ if backup_file and (backup_file.is_symlink() or not backup_file.is_file() or not os.access(backup_file, os.R_OK)):
945
+ print(
946
+ f"HINT: to take the managed block out as well, {by_hand}; your edits outside the block stay. "
947
+ f"Running the setup again would not help: the backup {backup_file} it keeps in the record is gone or unreadable."
948
+ )
949
+ return
950
+ print(
951
+ "HINT: to take the managed block out as well, run the setup again with --apply, which records the "
952
+ f"file as it is now, then undo again; your edits outside the block stay. Or {by_hand}."
953
+ )
954
+
955
+
956
+ def read_or_report(path: Path) -> bytes | None:
957
+ """PATH's bytes, or None after saying why the undo stops before it changes anything.
958
+
959
+ A file that cannot be read cannot be told apart from one without the managed block, and
960
+ taking it for one would remove the links and the record while the block stays.
961
+ """
962
+
963
+ try:
964
+ return path.read_bytes()
965
+ except OSError:
966
+ print(f"ERROR: cannot read {path}, so the undo cannot tell what it holds; nothing was changed", file=sys.stderr)
967
+ print("HINT: make it readable for this account, then undo again", file=sys.stderr)
968
+ return None
969
+
970
+
971
+ def rollback(args: argparse.Namespace, state_file: Path) -> int:
972
+ try:
973
+ metadata = state_file.lstat()
974
+ except FileNotFoundError:
975
+ print(f"ERROR: rollback state not found: {state_file}", file=sys.stderr)
976
+ return 3
977
+ except OSError as exc:
978
+ print(f"ERROR: cannot check {state_file}: {exc.strerror}; nothing was changed", file=sys.stderr)
979
+ print("HINT: give this account access to the folders on that path, then undo again", file=sys.stderr)
980
+ return 3
981
+ if state_file.is_symlink() or not state_file.is_file():
982
+ print(f"ERROR: rollback state must be a regular file: {state_file}", file=sys.stderr)
983
+ return 3
984
+ state = json.loads(state_file.read_text(encoding="utf-8"))
985
+ if not isinstance(state, dict) or not isinstance(state.get("destination"), str):
986
+ print("ERROR: invalid rollback state", file=sys.stderr)
987
+ return 3
988
+ destination = Path(state["destination"])
989
+ created = state.get("created_skills", [])
990
+ if not isinstance(created, list) or len(created) != len(set(created)) or any(name not in BY_SKILL for name in created):
991
+ print("ERROR: rollback state contains invalid skill ownership entries", file=sys.stderr)
992
+ return 3
993
+ try:
994
+ directories = recorded_directories(state, state_file)
995
+ except ValueError as exc:
996
+ print(f"ERROR: rollback state is not credible: {exc}", file=sys.stderr)
997
+ return 3
998
+ profile = state.get("profile", {})
999
+ target_path = Path(str(profile.get("target", ""))) if profile else None
1000
+ backup = profile.get("backup") if profile else None
1001
+ if target_path:
1002
+ allowed_paths = set()
1003
+ for client in CLIENTS:
1004
+ for environ in (args.environ, {}):
1005
+ try:
1006
+ allowed_paths.add(instruction_file(client, args.home, environ))
1007
+ except ValueError:
1008
+ # Another client's broken variable must not block this undo.
1009
+ pass
1010
+ if target_path not in allowed_paths:
1011
+ print(f"ERROR: refusing unexpected personalization target: {target_path}", file=sys.stderr)
1012
+ setting = recorded_setting(args.client, args.home, state.get("destination"), str(target_path))
1013
+ if setting:
1014
+ print(f"HINT: if the setup went there, it was made with {setting}; undo it with that setting", file=sys.stderr)
1015
+ return 3
1016
+ if backup:
1017
+ backup_file = Path(str(backup))
1018
+ if backup_file.parent != target_path.parent or not backup_file.name.startswith(target_path.name + ".bak."):
1019
+ print(f"ERROR: refusing unexpected personalization backup: {backup_file}", file=sys.stderr)
1020
+ return 3
1021
+ expected_hash = profile.get("written_sha256")
1022
+ if target_path and (not isinstance(expected_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_hash)):
1023
+ print("ERROR: invalid personalization readback hash in rollback state", file=sys.stderr)
1024
+ return 3
1025
+ if target_path and backup and not target_path.exists():
1026
+ print(f"PRESERVED: personalization target is missing: {target_path}")
1027
+ return 3
1028
+ restored: bytes | None = None
1029
+ original: bytes | None = None
1030
+ # An instruction file edited since the setup stays exactly as it is. With its managed block
1031
+ # taken out by hand the undo is otherwise complete; with the block still there only the
1032
+ # links go. The backup is not needed either way.
1033
+ profile_changed = False
1034
+ block_gone = False
1035
+ if target_path and target_path.exists():
1036
+ current = read_or_report(target_path)
1037
+ if current is None:
1038
+ return 3
1039
+ expected_hash = profile.get("written_sha256")
1040
+ if hashlib.sha256(current).hexdigest() != expected_hash:
1041
+ block_gone = BLOCK_START not in current and BLOCK_END not in current
1042
+ profile_changed = not block_gone
1043
+ if target_path and target_path.exists() and not profile_changed and not block_gone:
1044
+ backup_file = Path(str(backup)) if backup else None
1045
+ if backup_file and (backup_file.is_symlink() or not backup_file.is_file()):
1046
+ print(f"ERROR: personalization backup must be a regular file: {backup_file}", file=sys.stderr)
1047
+ return 3
1048
+ original = read_or_report(backup_file) if backup_file else None
1049
+ if backup_file and original is None:
1050
+ return 3
1051
+ try:
1052
+ restored = restored_content(current, original)
1053
+ except ValueError:
1054
+ print(f"PRESERVED: managed block not found in {target_path}")
1055
+ return 3
1056
+ for name in created:
1057
+ target = destination / name
1058
+ if target.is_symlink():
1059
+ if not links_to(target, SKILLS_DIR / name):
1060
+ print(f"PRESERVED: changed target {target}")
1061
+ return 3
1062
+ elif target.exists():
1063
+ print(f"PRESERVED: changed target {target}")
1064
+ return 3
1065
+ print(f"ROLLBACK_PLAN: remove={','.join(created) or 'none'}; personalization={target_path or 'none'}; backup={backup or 'none'}")
1066
+ if profile_changed:
1067
+ print_changed_profile(target_path, backup)
1068
+ else:
1069
+ if block_gone:
1070
+ print(f"INSTRUCTIONS_KEPT: {target_path} no longer holds the managed block, so it is left as it is")
1071
+ for directory in sorted(directories, key=lambda item: len(item.parts), reverse=True):
1072
+ print(f"ROLLBACK_FOLDER: {directory}: removed if it is still empty; the setup created it")
1073
+ if not args.apply:
1074
+ print(f"ROLLBACK_DRY_RUN: no files changed; pass --apply to reverse setup from {state_file}")
1075
+ return 0
1076
+ removed: list[str] = []
1077
+ for name in created:
1078
+ target = destination / name
1079
+ source = SKILLS_DIR / name
1080
+ if links_to(target, source):
1081
+ target.unlink()
1082
+ removed.append(name)
1083
+ elif target.exists() or target.is_symlink():
1084
+ print(f"PRESERVED: changed target {target}")
1085
+
1086
+ if target_path and restored is not None:
1087
+ if original is None and not restored.strip():
1088
+ # The setup created the file, and it holds nothing else.
1089
+ target_path.unlink()
1090
+ if target_path.exists() or target_path.is_symlink():
1091
+ raise OSError(f"personalization file remained after rollback: {target_path}")
1092
+ print(f"REMOVED_MANAGED_BLOCK: {target_path}")
1093
+ else:
1094
+ mode = target_path.stat().st_mode & 0o777
1095
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target_path.name}.rollback.", dir=target_path.parent)
1096
+ temporary = Path(temporary_name)
1097
+ try:
1098
+ with os.fdopen(descriptor, "wb") as handle:
1099
+ handle.write(restored)
1100
+ handle.flush()
1101
+ os.fsync(handle.fileno())
1102
+ temporary.chmod(mode)
1103
+ os.replace(temporary, target_path)
1104
+ except Exception:
1105
+ temporary.unlink(missing_ok=True)
1106
+ raise
1107
+ if target_path.read_bytes() != restored:
1108
+ raise OSError(f"personalization restore readback mismatch: {target_path}")
1109
+ if restored == original:
1110
+ print(f"RESTORED: {target_path} from {backup}")
1111
+ else:
1112
+ print(f"REMOVED_MANAGED_BLOCK: {target_path}; the rest of the file is kept as it is now")
1113
+
1114
+ if state_file.lstat().st_ino != metadata.st_ino or state_file.lstat().st_dev != metadata.st_dev:
1115
+ print(f"ERROR: rollback state changed during rollback; preserved at {state_file}", file=sys.stderr)
1116
+ return 3
1117
+ for name in removed:
1118
+ target = destination / name
1119
+ if target.exists() or target.is_symlink():
1120
+ raise OSError(f"skill rollback readback mismatch: {target}")
1121
+ if profile_changed:
1122
+ # The record stays, with no links left in it, so that the block can still be taken
1123
+ # out once the record knows the file as it is now.
1124
+ save_state(state_file, {**state, "created_skills": []})
1125
+ print_changed_profile(target_path, backup)
1126
+ print(f"ROLLBACK_READBACK: removed={','.join(removed) or 'none'}; profile=preserved")
1127
+ return 3
1128
+ state_file.unlink()
1129
+ removed_dirs, kept_dirs = remove_empty_directories(directories)
1130
+ for directory in removed_dirs:
1131
+ print(f"REMOVED_DIR: {directory}")
1132
+ for directory in kept_dirs:
1133
+ print(f"PRESERVED: {directory}: a folder above it is a link now, so it is left as it is")
1134
+ if "created_dirs" not in state:
1135
+ # A record from before the folder list still gets its skills folder removed when empty.
1136
+ # One with the list says which folders the setup made, so no other is touched.
1137
+ try:
1138
+ destination.rmdir()
1139
+ except OSError:
1140
+ pass
1141
+ print(f"ROLLBACK_READBACK: removed={','.join(removed) or 'none'}; profile={'kept' if block_gone else 'restored'}")
1142
+ return 0
1143
+
1144
+
1145
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
1146
+ parser = argparse.ArgumentParser(description=__doc__)
1147
+ parser.add_argument("--repo-url", required=True, help="repository link supplied for setup context")
1148
+ parser.add_argument("--repo-path", type=Path, help="local checkout to inspect instead of contacting GitHub")
1149
+ parser.add_argument("--offline", action="store_true", help="skip network lookup and use only supplied context and local paths")
1150
+ parser.add_argument("--client", choices=("codex", "claude", "opencode", "copilot"), required=True)
1151
+ parser.add_argument("--work-profile", choices=PROFILE_CHOICES)
1152
+ parser.add_argument("--goal", help="task or outcome the setup should support")
1153
+ parser.add_argument("--tools", help="client and tools already in use")
1154
+ parser.add_argument("--existing-setup", help="existing skills or workflow setup")
1155
+ parser.add_argument("--selection", help="comma-separated recommendation IDs; required for --apply")
1156
+ parser.add_argument("--preferences", help="short owner-approved persistent setup preference")
1157
+ parser.add_argument(
1158
+ "--home",
1159
+ type=Path,
1160
+ help="profile home for the documented default locations; when omitted, "
1161
+ "CLAUDE_CONFIG_DIR, CODEX_HOME and XDG_CONFIG_HOME are honoured",
1162
+ )
1163
+ parser.add_argument("--dest", type=Path, help="skill target directory override for controlled setup/tests")
1164
+ parser.add_argument("--state-file", type=Path, help=argparse.SUPPRESS)
1165
+ parser.add_argument("--apply", action="store_true", help="apply the reviewed selected subset")
1166
+ parser.add_argument("--rollback", action="store_true", help="reverse the most recent managed setup")
1167
+ return parser.parse_args(argv)
1168
+
1169
+
1170
+ def main(argv: list[str] | None = None) -> int:
1171
+ for stream in (sys.stdout, sys.stderr):
1172
+ try:
1173
+ # Piped output on Windows uses the ANSI code page, which cannot print every
1174
+ # character of a path or of the helpers' output.
1175
+ stream.reconfigure(encoding="utf-8", errors="replace")
1176
+ except (AttributeError, ValueError):
1177
+ pass
1178
+ args = parse_args(argv)
1179
+ try:
1180
+ # An explicit --home selects the documented defaults under it and ignores
1181
+ # the environment, which keeps isolated runs from touching a real profile.
1182
+ # The helpers run from the checkout, so a relative path would land inside it.
1183
+ args.home_explicit = args.home is not None
1184
+ args.environ = {} if args.home_explicit else os.environ
1185
+ if not args.home_explicit and os.name != "nt" and os.environ.get("HOME") == "":
1186
+ raise ValueError("HOME is set but empty")
1187
+ args.home = (
1188
+ Path(os.path.abspath(args.home.expanduser())) if args.home_explicit else Path.home()
1189
+ )
1190
+ if not args.home.is_absolute():
1191
+ raise ValueError(f"the home directory must be an absolute path, not {str(args.home)!r}")
1192
+ repo_url = safe_repo_url(args.repo_url)
1193
+ state = state_path(args.home, args.state_file, args.client)
1194
+ legacy_state = legacy_state_path(args.home, args.state_file)
1195
+ state_preimage: tuple[bytes, int] | None = None
1196
+ if args.rollback:
1197
+ if not state.exists() and not state.is_symlink() and legacy_state is not None and legacy_state.exists():
1198
+ legacy = read_state_file(legacy_state)
1199
+ legacy_owner = legacy_state_client(legacy, args.home)
1200
+ if legacy_owner is None:
1201
+ print(f"ERROR: legacy rollback state has no provable client owner: {legacy_state}", file=sys.stderr)
1202
+ return 3
1203
+ if legacy_owner != args.client:
1204
+ print(f"ERROR: legacy rollback state belongs to {legacy_owner}; no {args.client} state exists", file=sys.stderr)
1205
+ return 3
1206
+ state = legacy_state
1207
+ return rollback(args, state)
1208
+ previous: dict[str, object] = {}
1209
+ try:
1210
+ state_metadata = state.lstat()
1211
+ except FileNotFoundError:
1212
+ state_metadata = None
1213
+ if state_metadata is not None:
1214
+ previous = read_state_file(state)
1215
+ state_preimage = read_file_preimage(state)
1216
+ elif legacy_state is not None and legacy_state.exists():
1217
+ legacy = read_state_file(legacy_state)
1218
+ legacy_owner = legacy_state_client(legacy, args.home)
1219
+ if legacy_owner is None:
1220
+ raise ValueError(f"legacy setup state has no provable client owner; preserve and inspect it: {legacy_state}")
1221
+ if legacy_owner == args.client:
1222
+ previous = legacy
1223
+ print(f"LEGACY_STATE_MIGRATION: this {args.client} setup will be copied to {state}; original retained at {legacy_state}")
1224
+ else:
1225
+ print(f"LEGACY_STATE_PRESERVED: belongs to {legacy_owner}; current client={args.client}")
1226
+ ask_if_missing(args)
1227
+ if args.apply and not args.selection:
1228
+ raise ValueError("--apply requires an explicit --selection")
1229
+ if args.preferences and _sibling(PERSONALIZATION).preference_problem(args.preferences):
1230
+ raise ValueError(_sibling(PERSONALIZATION).preference_problem(args.preferences))
1231
+ if args.repo_path is not None:
1232
+ repository_context = inspect_local_repository(args.repo_path)
1233
+ elif args.offline:
1234
+ repository_context = RepositoryContext("OFFLINE")
1235
+ else:
1236
+ repository_context = inspect_github_repository(repo_url)
1237
+ destination = (
1238
+ args.dest or skills_destination(args.client, args.home, args.environ)
1239
+ ).expanduser().resolve()
1240
+ require_utf8(args.home, destination)
1241
+ if previous and Path(str(previous["destination"])).expanduser().resolve() != destination:
1242
+ setting = recorded_setting(
1243
+ args.client, args.home, previous["destination"], (previous.get("profile") or {}).get("target")
1244
+ )
1245
+ if setting is None:
1246
+ remedy = "undo it with --rollback --apply and the --home or --dest it was made with"
1247
+ elif setting.endswith(" unset"):
1248
+ # After an upgrade: 2.0.0 used the default place even when the variable was set.
1249
+ remedy = f"undo it with {setting} and --rollback --apply, or pass --home to keep that location"
1250
+ else:
1251
+ remedy = f"undo it with {setting} and --rollback --apply"
1252
+ raise ValueError(
1253
+ "existing setup state belongs to a different skills destination: "
1254
+ f"{previous['destination']} ({remedy}, then run this command again)"
1255
+ )
1256
+ before = installed_ids(destination)
1257
+ ranked = recommendations(
1258
+ args.work_profile,
1259
+ args.goal,
1260
+ args.tools,
1261
+ args.existing_setup,
1262
+ before,
1263
+ repository_context,
1264
+ )
1265
+ print(f"REPOSITORY: {display_repo_url(repo_url)}")
1266
+ print(f"REPOSITORY_SOURCE: {repository_context.status}; {repository_context.summary or 'repository content unavailable; using supplied answers only'}")
1267
+ if repository_context.status.startswith("LOCAL_") or repository_context.status.startswith("GITHUB_"):
1268
+ print("REPOSITORY_TRUST: README and instruction content treated as untrusted data; no scripts, hooks or installers executed.")
1269
+ for name, value in (("goal", args.goal), ("tools", args.tools), ("existing setup", args.existing_setup)):
1270
+ if len(value) > 240 or any(ord(char) < 0x20 for char in value):
1271
+ raise ValueError(f"{name} must be a single line of at most 240 characters")
1272
+ print(f"NEEDS: profile={args.work_profile}; goal={args.goal}; tools={args.tools}; existing={args.existing_setup}")
1273
+ capability = inspect_client_config(args.client, args.home, args.environ)
1274
+ print(
1275
+ "CLIENT_CAPABILITY_AUDIT: "
1276
+ f"client={args.client}; config_status={capability['config_status']}; "
1277
+ f"config_path={capability['config_path']}; "
1278
+ f"configured_main={capability['configured_main_model']}; "
1279
+ f"configured_effort={capability['configured_effort']}; "
1280
+ "active_profile=UNKNOWN; advertised_models=UNKNOWN; callable_workers=UNKNOWN; "
1281
+ "runtime_main_and_effort=UNKNOWN; runtime_context=UNKNOWN"
1282
+ )
1283
+ audit_skill = SKILLS_DIR / "nobrainer-auto-fine-tune" / "SKILL.md"
1284
+ if not audit_skill.is_file():
1285
+ raise ValueError(f"required Auto Fine Tune skill is missing: {audit_skill}")
1286
+ skill_text = audit_skill.read_text(encoding="utf-8")
1287
+ if "Preserve the owner's choice" not in skill_text or "Discover this client's actual capabilities" not in skill_text:
1288
+ raise ValueError(f"Auto Fine Tune audit instructions are incomplete: {audit_skill}")
1289
+ print("CAPABILITY_AUDIT: skill=nobrainer-auto-fine-tune; MAIN=UNKNOWN (preserved); effort=UNKNOWN; callable workers=UNKNOWN; runtime context=UNKNOWN; measurement=UNMEASURED")
1290
+ audit = SKILLS_DIR / "nobrainer-auto-fine-tune" / "references" / "capacity-audit.md"
1291
+ if not audit.is_file():
1292
+ raise ValueError(f"required Auto Fine Tune audit reference is missing: {audit}")
1293
+ audit_text = audit.read_text(encoding="utf-8")
1294
+ if "Separate four evidence layers" not in audit_text or "Client adapters" not in audit_text:
1295
+ raise ValueError(f"Auto Fine Tune capacity-audit reference is incomplete: {audit}")
1296
+ print(f"AUDIT_REFERENCE_READ: {audit.relative_to(ROOT)}")
1297
+ print("REQUIRED: 01,02 (nobrainer-tech-flow entrypoint plus standard read-only capability audit)")
1298
+ print("AVAILABLE OPTIONS:")
1299
+ for item in ITEMS:
1300
+ print(f"{item.id} {item.label}: {item.reason}")
1301
+ print("RECOMMENDATIONS (fit is a task-specific heuristic, not a benchmark):")
1302
+ for item, score, reason in ranked:
1303
+ conflict = target_state(destination, item)
1304
+ print(
1305
+ f"{item.id} {item.label} | Fit (heuristic)={score}/10 | {reason} | "
1306
+ f"Dependencies=01,02 | Target={conflict} | "
1307
+ f"Change scope=install {item.skill} and update selected client nobrainer-tech-flow instructions"
1308
+ )
1309
+ selection_text = args.selection
1310
+ if not selection_text and stdin_is_terminal():
1311
+ try:
1312
+ selection_text = input("Choose any recommendation IDs to install (comma-separated, blank to stop): ").strip()
1313
+ except EOFError:
1314
+ selection_text = ""
1315
+ chosen = selected_ids(selection_text)
1316
+ if not chosen:
1317
+ print("DRY_RUN: no files changed; choose IDs with --selection and review this plan.")
1318
+ return 0
1319
+ install_ids = sorted(set(chosen) | {"01", "02"})
1320
+ install_names = [BY_ID[item_id].skill for item_id in install_ids]
1321
+ new_skills = [name for name in install_names if BY_SKILL[name].id not in before]
1322
+ print(f"SELECTED: {','.join(chosen)}")
1323
+ print(f"INSTALL_SET: {','.join(install_ids)} (includes required 01,02)")
1324
+ # The installer runs once per skill, so it cannot see the whole set; judge the
1325
+ # cross-skill links here and hide its per-skill notes below.
1326
+ for name, other in _sibling(INSTALL_SKILLS).unmet_references(
1327
+ install_names, {skill: SKILLS_DIR / skill for skill in BY_SKILL}, destination
1328
+ ):
1329
+ print(
1330
+ f"NOTE: {name} links to {other}, which is not in this install set; "
1331
+ f"add selection {BY_SKILL[other].id} to include it"
1332
+ )
1333
+ personalization_client = args.client
1334
+ home_args = ["--home", str(args.home)] if args.home_explicit else []
1335
+ # The guided flow cannot set auto-update, session-restart or a wiki root, so
1336
+ # it must keep whatever an earlier run of the personalization script granted.
1337
+ profile_args = [sys.executable, str(PERSONALIZATION), "--client", personalization_client, *home_args, "--keep-options"]
1338
+ if args.preferences:
1339
+ profile_args.extend(("--preferences", args.preferences))
1340
+ code, profile_output = run(profile_args)
1341
+ if code:
1342
+ profile_failure_hint(profile_output)
1343
+ return code
1344
+ inherited = "INHERITS_CODEX:" in profile_output
1345
+ if inherited and args.preferences:
1346
+ personalization_client = "codex"
1347
+ profile_args = [sys.executable, str(PERSONALIZATION), "--client", personalization_client, *home_args, "--keep-options", "--preferences", args.preferences]
1348
+ code, profile_output = run(profile_args)
1349
+ if code:
1350
+ profile_failure_hint(profile_output)
1351
+ return code
1352
+ profile_target = instruction_file(personalization_client, args.home, args.environ)
1353
+ require_utf8(profile_target)
1354
+ old_profile = previous.get("profile", {})
1355
+ if old_profile.get("target") and Path(str(old_profile["target"])) != profile_target:
1356
+ setting = recorded_setting(args.client, args.home, previous.get("destination"), old_profile["target"])
1357
+ with_setting = f" with {setting} and" if setting else " with"
1358
+ raise ValueError(
1359
+ "existing setup state belongs to a different personalization target: "
1360
+ f"{old_profile['target']} (an earlier run wrote its instructions there; undo that "
1361
+ f"setup{with_setting} --rollback --apply, then run this command again)"
1362
+ )
1363
+ if not args.apply:
1364
+ return 0
1365
+ # The folders this run may create, so that an undo can remove the ones it made.
1366
+ # The record's own folder counts: a home that does not exist yet is made for it alone
1367
+ # when a client variable puts the skills and the instructions elsewhere.
1368
+ new_directories = missing_directories(destination, profile_target.parent, state.parent)
1369
+ # Inspect every target before writing any of them. This keeps a later
1370
+ # conflict from leaving a partial selection installed.
1371
+ for name in install_names:
1372
+ command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name]
1373
+ code, _ = run(command, hide=("NOTE: ",))
1374
+ if code:
1375
+ return code
1376
+ # The record's folder is made before anything else is written: when it cannot be made,
1377
+ # the run stops here instead of leaving links and a block that no record describes.
1378
+ state.parent.mkdir(parents=True, exist_ok=True)
1379
+ profile_preimage = read_file_preimage(profile_target)
1380
+ if args.apply:
1381
+ for name in install_names:
1382
+ command = [sys.executable, str(ROOT / "scripts" / "install_skills.py"), "--client", args.client, "--dest", str(destination), "--skill", name, "--apply"]
1383
+ code, _ = run(command, hide=("NOTE: ",))
1384
+ if code:
1385
+ remove_exact_created_links(destination, new_skills)
1386
+ return code
1387
+ if not inherited or args.preferences:
1388
+ profile_args.append("--apply")
1389
+ code, profile_output = run(profile_args)
1390
+ if code:
1391
+ # A profile write can race after preflight. Reverse only the
1392
+ # exact new symlinks from this invocation before returning.
1393
+ remove_exact_created_links(destination, new_skills)
1394
+ return code
1395
+ backup_match = re.search(r"^BACKUP: (.+)$", profile_output, re.MULTILINE)
1396
+ if inherited and not args.preferences:
1397
+ profile = {}
1398
+ else:
1399
+ if old_profile.get("target"):
1400
+ # Undo puts the block back as it was before the first setup, so a later
1401
+ # run keeps the first backup (or its absence), not the one it just made.
1402
+ recorded_backup = old_profile.get("backup")
1403
+ elif backup_match:
1404
+ recorded_backup = backup_match.group(1)
1405
+ elif profile_preimage is not None:
1406
+ # The file already held this exact block, so nothing was written and no
1407
+ # backup was made: keep its preimage so undo leaves that block in place.
1408
+ recorded_backup = str(save_preimage(profile_target, profile_preimage))
1409
+ else:
1410
+ recorded_backup = None
1411
+ profile = {
1412
+ "target": str(profile_target),
1413
+ "backup": recorded_backup,
1414
+ "written_sha256": hashlib.sha256(profile_target.read_bytes()).hexdigest(),
1415
+ }
1416
+ created = sorted(set(previous.get("created_skills", [])) | {name for name in new_skills if (destination / name).is_symlink()})
1417
+ next_state = {"destination": str(destination), "created_skills": created, "profile": profile}
1418
+ # Folders an earlier run made stay in the record as recorded, links already resolved
1419
+ # then. Resolving them again would follow a link put in their place since, and hand the
1420
+ # folder it points to over to the undo. A record from before this field existed has
1421
+ # none: its folders may have been there already, so none is claimed. A folder this run
1422
+ # made is written the way it really is, links resolved, so that runs from different
1423
+ # installers and spellings of the same path list it once.
1424
+ previous_dirs = {str(path) for path in recorded_directories(previous, state)} if previous else set()
1425
+ made_dirs = {str(physical_path(path)) for path in new_directories if path.is_dir() and not path.is_symlink()}
1426
+ created_dirs = sorted(previous_dirs | made_dirs)
1427
+ # A record that has no folder list is from an earlier version. It keeps that form until
1428
+ # a run makes a folder, so that its undo follows the rule it was made under.
1429
+ if created_dirs or not previous or "created_dirs" in previous:
1430
+ next_state["created_dirs"] = created_dirs
1431
+ try:
1432
+ save_state(state, next_state)
1433
+ except (OSError, TypeError, ValueError) as exc:
1434
+ rollback_errors: list[str] = []
1435
+ if profile.get("target") and "APPLIED: " in profile_output:
1436
+ restore_preimage = profile_preimage
1437
+ if backup_match:
1438
+ backup_path = Path(backup_match.group(1))
1439
+ try:
1440
+ backup_metadata = backup_path.lstat()
1441
+ if (
1442
+ backup_path.parent != profile_target.parent
1443
+ or not backup_path.name.startswith(profile_target.name + ".bak.")
1444
+ or stat.S_ISLNK(backup_metadata.st_mode)
1445
+ or not stat.S_ISREG(backup_metadata.st_mode)
1446
+ ):
1447
+ raise ValueError("personalization backup is not an exact regular sibling")
1448
+ restore_preimage = (
1449
+ backup_path.read_bytes(),
1450
+ stat.S_IMODE(backup_metadata.st_mode),
1451
+ )
1452
+ except (OSError, ValueError) as rollback_exc:
1453
+ rollback_errors.append(f"personalization preimage unavailable: {rollback_exc}")
1454
+ if not rollback_errors:
1455
+ try:
1456
+ profile_restored = restore_file_preimage(
1457
+ profile_target,
1458
+ str(profile["written_sha256"]),
1459
+ restore_preimage,
1460
+ )
1461
+ except (OSError, ValueError) as rollback_exc:
1462
+ profile_restored = False
1463
+ rollback_errors.append(f"personalization rollback failed: {rollback_exc}")
1464
+ if not profile_restored:
1465
+ rollback_errors.append(f"personalization changed during rollback: {profile_target}")
1466
+
1467
+ try:
1468
+ remove_exact_created_links(destination, new_skills)
1469
+ except OSError as rollback_exc:
1470
+ rollback_errors.append(f"skill rollback failed: {rollback_exc}")
1471
+ remaining_links = [
1472
+ name
1473
+ for name in new_skills
1474
+ if (destination / name).exists() or (destination / name).is_symlink()
1475
+ ]
1476
+ if remaining_links:
1477
+ rollback_errors.append("new skill targets changed and were preserved: " + ",".join(remaining_links))
1478
+
1479
+ next_state_bytes = (json.dumps(next_state, indent=2) + "\n").encode("utf-8")
1480
+ try:
1481
+ state_restored = restore_file_preimage(
1482
+ state,
1483
+ hashlib.sha256(next_state_bytes).hexdigest(),
1484
+ state_preimage,
1485
+ )
1486
+ except (OSError, ValueError) as rollback_exc:
1487
+ state_restored = False
1488
+ rollback_errors.append(f"setup state rollback failed: {rollback_exc}")
1489
+ if not state_restored:
1490
+ rollback_errors.append(f"setup state changed during rollback: {state}")
1491
+
1492
+ detail = f"{exc}; rollback incomplete: {'; '.join(rollback_errors)}" if rollback_errors else f"{exc}; applied changes were rolled back and read back"
1493
+ print(f"ERROR: setup state save failed: {detail}", file=sys.stderr)
1494
+ return 3 if rollback_errors else 2
1495
+ after = installed_ids(destination)
1496
+ actual_added = sorted(after - before)
1497
+ expected_added = sorted(BY_SKILL[name].id for name in new_skills)
1498
+ if actual_added != expected_added:
1499
+ print(f"ERROR: install readback mismatch expected={expected_added} actual={actual_added}", file=sys.stderr)
1500
+ return 3
1501
+ for item_id in set(BY_ID) - set(install_ids) - before:
1502
+ if (destination / BY_ID[item_id].skill).exists() or (destination / BY_ID[item_id].skill).is_symlink():
1503
+ print(f"ERROR: unselected item was installed: {item_id}", file=sys.stderr)
1504
+ return 3
1505
+ print(f"READBACK: installed={','.join(sorted(after))}; rejected/unselected remain absent unless previously present")
1506
+ if profile.get("target"):
1507
+ print(f"PREFERENCE_READBACK: {profile_target}; hash={profile['written_sha256']}")
1508
+ else:
1509
+ print("PREFERENCE_READBACK: inherited from Codex global instructions; no duplicate block written")
1510
+ print(f"ROLLBACK_STATE: {state}")
1511
+ print("CAPABILITY_AUDIT_LIMIT: host runtime evidence remains UNKNOWN; run the loaded skill in the active client before recommending route changes.")
1512
+ return 0
1513
+ except InputRequired as exc:
1514
+ print(f"INPUT_REQUIRED: {exc}", file=sys.stderr)
1515
+ return 2
1516
+ except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
1517
+ print(f"ERROR: {exc}", file=sys.stderr)
1518
+ return 2
1519
+
1520
+
1521
+ if __name__ == "__main__":
1522
+ raise SystemExit(main())