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,600 @@
1
+ #!/usr/bin/env python3
2
+ """Install, preview or undo nobrainer-tech-flow for one client with one command.
3
+
4
+ Every run is a dry run until --apply. The skills are linked and the managed
5
+ instruction block is written by the same helpers as the manual and guided setups,
6
+ and --undo uses the guided setup's rollback state (as scripts/install.sh does), so
7
+ every path reverses the same way. This command never grants a standing authorization.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import importlib.util
14
+ import os
15
+ import re
16
+ import shlex
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import NoReturn
23
+
24
+
25
+ ROOT = Path(__file__).resolve().parents[1]
26
+ GUIDED = ROOT / "scripts" / "recommend_flow_setup.py"
27
+ INSTALL_SKILLS = ROOT / "scripts" / "install_skills.py"
28
+ PERSONALIZATION = ROOT / "scripts" / "install_personalization.py"
29
+ GUARDS = ROOT / "scripts" / "install_guards.py"
30
+ # The guided setup wants a repository link for context; this command reads none.
31
+ REPO_URL = "https://github.com/nobrainer-tech/nobrainer-tech-flow"
32
+ LABELS = {
33
+ "claude": "Claude Code",
34
+ "codex": "Codex",
35
+ "opencode": "OpenCode",
36
+ "copilot": "GitHub Copilot CLI",
37
+ }
38
+ # The variable each client reads to find its configuration.
39
+ VARIABLES = {"claude": "CLAUDE_CONFIG_DIR", "codex": "CODEX_HOME", "opencode": "XDG_CONFIG_HOME"}
40
+ PYTHON = "py -3" if os.name == "nt" else "python3"
41
+ # Lines of a helper's output that explain why it stopped or need the reader's eye.
42
+ PROBLEM = re.compile(
43
+ r"^(ERROR|HINT|PRESERVED|WARNING|NOTE|CONFLICT\w*|LEGACY\w*|UNMAPPED_CONFLICT|"
44
+ r"MIGRATE\w*|ROLLBACK\w*|BACKUP_PRESERVED|INPUT_REQUIRED): "
45
+ )
46
+ KEPT = re.compile(r"^(KEPT_OPTIONS|NOTE|WARNING|HINT|LEGACY_STATE_\w+|BACKUP_PRESERVED): ")
47
+ UNDO_NOTE = re.compile(r"^(PRESERVED|NOTE|WARNING|HINT): ")
48
+ # The helpers name their own flags; this command has others for the same thing.
49
+ TRANSLATIONS = (
50
+ ("--rollback --apply", "--undo --apply"),
51
+ ("rerun with --migrate-legacy --apply", "migrate it first, as the hint below says"),
52
+ )
53
+ CONFLICT_HINT = (
54
+ "a target above exists and is not a link to this checkout. Inspect it and move it away if "
55
+ f"it is yours to remove, or install the other skills one by one with {PYTHON} "
56
+ "scripts/install_skills.py --skill (docs/INSTALL.md)."
57
+ )
58
+ OVERRIDE_HINT = (
59
+ "Codex reads AGENTS.override.md instead of AGENTS.md; remove or empty that file and rerun, "
60
+ f"or write to it on purpose with {PYTHON} scripts/install_personalization.py --client codex "
61
+ "--path <file>."
62
+ )
63
+
64
+
65
+ class Refused(Exception):
66
+ """A helper refused; its reason is already printed and this is the exit code."""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Context:
71
+ client: str
72
+ apply: bool
73
+ home: Path
74
+ home_args: tuple[str, ...]
75
+ skills: Path
76
+ guided: object
77
+ # "NAME=value" when the client's own variable chose the locations.
78
+ variable: str | None = None
79
+
80
+ @property
81
+ def label(self) -> str:
82
+ return LABELS[self.client]
83
+
84
+ def command(self, *flags: str) -> str:
85
+ """The command that continues this run, keeping an explicit --home."""
86
+
87
+ parts = [PYTHON, "scripts/install.py", "--client", self.client, *flags]
88
+ if self.home_args:
89
+ parts += ["--home", quote(self.home_args[1])]
90
+ return " ".join(parts)
91
+
92
+ def variable_note(self) -> str | None:
93
+ if not self.variable:
94
+ return None
95
+ return f"NOTE: this setup follows {self.variable}; keep it set when you undo it."
96
+
97
+
98
+ def quote(value: str) -> str:
99
+ return subprocess.list2cmdline([value]) if os.name == "nt" else shlex.quote(value)
100
+
101
+
102
+ def unique(lines: list[str]) -> list[str]:
103
+ """The lines in order, each once: the guided setup can run a helper twice."""
104
+
105
+ return list(dict.fromkeys(lines))
106
+
107
+
108
+ def load_module(name: str, path: Path):
109
+ spec = importlib.util.spec_from_file_location(name, path)
110
+ if spec is None or spec.loader is None:
111
+ raise ValueError(f"cannot load {path}")
112
+ module = importlib.util.module_from_spec(spec)
113
+ # dataclasses resolve string annotations through sys.modules.
114
+ sys.modules[spec.name] = module
115
+ try:
116
+ spec.loader.exec_module(module)
117
+ except BaseException:
118
+ sys.modules.pop(spec.name, None)
119
+ raise
120
+ return module
121
+
122
+
123
+ def load_guided():
124
+ """The guided setup owns the client paths, the skill list and the rollback state."""
125
+
126
+ return load_module("_flow_guided_setup", GUIDED)
127
+
128
+
129
+ def guards(args: argparse.Namespace) -> int:
130
+ """The optional Claude Code guards; scripts/install_guards.py owns them."""
131
+
132
+ if args.client != "claude":
133
+ print(f"UNSUPPORTED: the Flow guards exist only for Claude Code, not {LABELS[args.client]}.")
134
+ return 3
135
+ if os.name == "nt":
136
+ print("UNSUPPORTED: the Flow guards are POSIX-only in this version.")
137
+ return 3
138
+ module = load_module("_flow_install_guards", GUARDS)
139
+ home = Path(os.path.abspath(args.home.expanduser())) if args.home is not None else None
140
+ rerun = f"{PYTHON} scripts/install.py --client claude --guards {{flags}}"
141
+ if home is not None:
142
+ rerun += f" --home {quote(str(home))}"
143
+ try:
144
+ return module.run(args.apply, args.undo, home, rerun)
145
+ except module.Refused as refused:
146
+ print(f"REFUSED: {refused}; nothing was changed", file=sys.stderr)
147
+ return 3
148
+
149
+
150
+ def guards_note(ctx: Context) -> str | None:
151
+ """A reminder that an undo leaves the guards, which then do nothing, in settings.json."""
152
+
153
+ if ctx.client != "claude" or os.name == "nt":
154
+ return None
155
+ module = load_module("_flow_install_guards", GUARDS)
156
+ settings, guard = module.locations(ctx.home if ctx.home_args else None)
157
+ if not module.installed(settings, guard):
158
+ return None
159
+ return (
160
+ f"NOTE: the optional Flow guards stay in {settings}; remove them with "
161
+ f"{ctx.command('--guards', '--undo', '--apply')}"
162
+ )
163
+
164
+
165
+ def run(command: list[str]) -> tuple[int, str, str]:
166
+ # The helpers write UTF-8 whatever the console code page is, and must never ask.
167
+ result = subprocess.run(
168
+ command,
169
+ cwd=ROOT,
170
+ encoding="utf-8",
171
+ errors="replace",
172
+ capture_output=True,
173
+ check=False,
174
+ stdin=subprocess.DEVNULL,
175
+ )
176
+ return result.returncode, result.stdout, result.stderr
177
+
178
+
179
+ def translate(text: str) -> str:
180
+ for helper_words, own_words in TRANSLATIONS:
181
+ text = text.replace(helper_words, own_words)
182
+ return text
183
+
184
+
185
+ def refuse(code: int, out: str, err: str, hint: str | None = None) -> NoReturn:
186
+ for line in unique(out.splitlines()):
187
+ if PROBLEM.match(line):
188
+ print(translate(line), file=sys.stderr)
189
+ if err.strip():
190
+ print(translate(err.rstrip()), file=sys.stderr)
191
+ if hint:
192
+ print(f"HINT: {hint}", file=sys.stderr)
193
+ raise Refused(code)
194
+
195
+
196
+ def git(*arguments: str) -> str | None:
197
+ try:
198
+ result = subprocess.run(
199
+ ["git", *arguments],
200
+ cwd=ROOT,
201
+ encoding="utf-8",
202
+ errors="replace",
203
+ capture_output=True,
204
+ check=False,
205
+ timeout=15,
206
+ )
207
+ except (OSError, subprocess.SubprocessError):
208
+ return None
209
+ return result.stdout.strip() if result.returncode == 0 else None
210
+
211
+
212
+ def source_line() -> str:
213
+ version = (ROOT / "skills" / "nobrainer-tech-flow" / "VERSION").read_text(encoding="utf-8").strip()
214
+ commit = git("rev-parse", "--short", "HEAD")
215
+ if not commit:
216
+ return f"nobrainer-tech-flow {version}"
217
+ changed = git("status", "--porcelain", "--untracked-files=no")
218
+ return f"nobrainer-tech-flow {version} (commit {commit}{', local changes' if changed else ''})"
219
+
220
+
221
+ def nearest_existing(path: Path) -> Path | None:
222
+ for candidate in (path, *path.parents):
223
+ if candidate.is_dir():
224
+ return candidate
225
+ return None
226
+
227
+
228
+ def symlinks_available(where: Path | None = None) -> bool:
229
+ """Whether this account can create directory links in WHERE (default: the temp folder)."""
230
+
231
+ try:
232
+ with tempfile.TemporaryDirectory(dir=where) as scratch:
233
+ target = Path(scratch) / "target"
234
+ target.mkdir()
235
+ os.symlink(target, Path(scratch) / "link", target_is_directory=True)
236
+ except (OSError, NotImplementedError):
237
+ return False
238
+ return True
239
+
240
+
241
+ def symlink_help(where: Path | None = None) -> str:
242
+ place = f" in {where}" if where else ""
243
+ return (
244
+ f"ERROR: this machine cannot create symbolic links{place}, which this command needs.\n"
245
+ "HINT: on Windows turn on Developer Mode (Settings > System > For developers) or run "
246
+ "from an elevated shell, then rerun. To copy the skills instead, see docs/INSTALL.md "
247
+ "(Windows)."
248
+ )
249
+
250
+
251
+ def plan_skills(ctx: Context) -> tuple[int, int]:
252
+ """Dry-run the skill install: (skills to link, skills already linked)."""
253
+
254
+ code, out, err = run(
255
+ [sys.executable, str(INSTALL_SKILLS), "--client", ctx.client, "--dest", str(ctx.skills)]
256
+ )
257
+ if code:
258
+ hint = None
259
+ if code == 3 and "legacy symlink detected" in err:
260
+ hint = (
261
+ "an entry above comes from an earlier layout of these skills. Migrate it with "
262
+ f"{PYTHON} scripts/install_skills.py --client {ctx.client} --dest "
263
+ f"{quote(str(ctx.skills))} --migrate-legacy --apply (docs/INSTALL.md, Conflict "
264
+ "and migration behavior), then run this again."
265
+ )
266
+ elif code == 3:
267
+ hint = CONFLICT_HINT
268
+ refuse(code, out, err, hint)
269
+ actions = [line.split(":", 1)[0] for line in out.splitlines()]
270
+ to_link = actions.count("SYMLINK")
271
+ if to_link and not symlinks_available():
272
+ print(symlink_help(), file=sys.stderr)
273
+ raise Refused(2)
274
+ return to_link, actions.count("KEEP")
275
+
276
+
277
+ def plan_instructions(ctx: Context) -> list[str]:
278
+ code, out, err = run(
279
+ [
280
+ sys.executable,
281
+ str(PERSONALIZATION),
282
+ "--client",
283
+ ctx.client,
284
+ *ctx.home_args,
285
+ "--keep-options",
286
+ ]
287
+ )
288
+ if code:
289
+ refuse(
290
+ code,
291
+ out,
292
+ err,
293
+ OVERRIDE_HINT if "takes precedence over" in out + err else None,
294
+ )
295
+ return [line for line in out.splitlines() if not line.startswith("DRY_RUN:")]
296
+
297
+
298
+ def guided_command(ctx: Context, *extra: str) -> list[str]:
299
+ return [
300
+ sys.executable,
301
+ str(GUIDED),
302
+ "--repo-url",
303
+ REPO_URL,
304
+ "--offline",
305
+ "--client",
306
+ ctx.client,
307
+ *ctx.home_args,
308
+ *extra,
309
+ ]
310
+
311
+
312
+ def every_skill(ctx: Context) -> tuple[str, ...]:
313
+ # Every skill is selected; the answers only feed the recommendations this run ignores.
314
+ return (
315
+ "--work-profile",
316
+ "software-development",
317
+ "--goal",
318
+ "Install every nobrainer-tech-flow skill",
319
+ "--tools",
320
+ ctx.label,
321
+ "--existing-setup",
322
+ "not inspected",
323
+ "--selection",
324
+ ",".join(item.id for item in ctx.guided.ITEMS),
325
+ )
326
+
327
+
328
+ def read_record(ctx: Context) -> dict[str, object] | None:
329
+ """The setup recorded for this client, as the guided setup reads it."""
330
+
331
+ guided = ctx.guided
332
+ for path in (guided.state_path(ctx.home, None, ctx.client), guided.legacy_state_path(ctx.home, None)):
333
+ if path is not None and path.is_file() and not path.is_symlink():
334
+ try:
335
+ return guided.read_state_file(path)
336
+ except (OSError, ValueError):
337
+ return None
338
+ return None
339
+
340
+
341
+ def unrecorded_path(ctx: Context) -> Path | None:
342
+ """Where this client's setup record would be, when no record exists at all.
343
+
344
+ Neither this client's record nor the older single-client one may be there, not even
345
+ as a link: anything that is there is a record to read, or to refuse. A path that cannot
346
+ be checked, because a folder on the way cannot be searched, raises OSError: a record may
347
+ be there, so it is not the same as none.
348
+ """
349
+
350
+ guided = ctx.guided
351
+ paths = (guided.state_path(ctx.home, None, ctx.client), guided.legacy_state_path(ctx.home, None))
352
+ for path in paths:
353
+ if path is None:
354
+ continue
355
+ try:
356
+ path.lstat()
357
+ except (FileNotFoundError, NotADirectoryError):
358
+ continue
359
+ return None
360
+ return paths[0]
361
+
362
+
363
+ def install(ctx: Context) -> int:
364
+ to_link, kept = plan_skills(ctx)
365
+ instructions = plan_instructions(ctx)
366
+ # The guided setup's own dry run checks the recorded setup, the paths and the
367
+ # target, so the preview refuses whatever --apply would refuse.
368
+ code, out, err = run(guided_command(ctx, *every_skill(ctx)))
369
+ if code:
370
+ refuse(code, out, err)
371
+ if not ctx.apply:
372
+ print(f"{source_line()} for {ctx.label}")
373
+ print(f"SKILLS: {to_link} to link into {ctx.skills} ({kept} already linked)")
374
+ for line in unique(instructions):
375
+ print(line)
376
+ note = ctx.variable_note()
377
+ if note:
378
+ print(note)
379
+ print(f"NO_CHANGES: preview only; install with: {ctx.command('--apply')}")
380
+ return 0
381
+ where = nearest_existing(ctx.skills)
382
+ if to_link and not symlinks_available(where):
383
+ print(symlink_help(where), file=sys.stderr)
384
+ raise Refused(2)
385
+ earlier = read_record(ctx) or {}
386
+ code, out, err = run(guided_command(ctx, *every_skill(ctx), "--apply"))
387
+ if code:
388
+ if os.name == "nt" and re.search(r"WinError 1314|privilege", out + err, re.IGNORECASE):
389
+ print(symlink_help(), file=sys.stderr)
390
+ refuse(code, out, err)
391
+ record = read_record(ctx) or {}
392
+ profile = record.get("profile") or {}
393
+ linked = len(ctx.guided.installed_ids(ctx.skills))
394
+ # New links and a written instruction file are what a client has to be restarted for.
395
+ # The personalization helper prints APPLIED only for a file it wrote.
396
+ written = re.search(r"^APPLIED: ", out, re.MULTILINE) is not None
397
+ changed = bool(to_link) or written
398
+ if changed:
399
+ print(f"INSTALLED: {source_line()} for {ctx.label}")
400
+ else:
401
+ print(f"UP TO DATE: {source_line()} is already installed for {ctx.label}; nothing changed")
402
+ print(f"SKILLS: {linked} linked in {ctx.skills} ({to_link} new)")
403
+ if profile.get("target"):
404
+ backup = profile.get("backup")
405
+ if written and re.search(r"^UPDATE: ", out, re.MULTILINE):
406
+ # A block of an earlier version was replaced. This run's backup holds the file as
407
+ # it was just before; the recorded one is the file from before the setup.
408
+ made = re.search(r"^BACKUP: (.+)$", out, re.MULTILINE)
409
+ print(
410
+ f"INSTRUCTIONS: {profile['target']}: managed block updated to this version's text; "
411
+ "the rest of the file is kept as it is" + (f" (backup: {made.group(1)})" if made else "")
412
+ )
413
+ elif written:
414
+ print(f"INSTRUCTIONS: {profile['target']}" + (f" (backup: {backup})" if backup else ""))
415
+ else:
416
+ print(f"INSTRUCTIONS: {profile['target']} already has the managed block; left as it is")
417
+ else:
418
+ print("INSTRUCTIONS: inherited from the Codex global instructions; nothing written here")
419
+ for line in unique(out.splitlines()):
420
+ if KEPT.match(line):
421
+ print(line)
422
+ recorded = (earlier.get("profile") or {}).get("written_sha256")
423
+ if not changed and earlier and profile.get("written_sha256") not in (None, recorded):
424
+ print("RECORD: updated to the instruction file as it is now, so an undo keeps your edits outside the block")
425
+ print(f"UNDO: {ctx.command('--undo', '--apply')}")
426
+ note = ctx.variable_note()
427
+ if note:
428
+ print(note)
429
+ if changed:
430
+ print(
431
+ f"NEXT: restart {ctx.label}, then give it one small task with a checkable result "
432
+ "(docs/TRY_IT.md)."
433
+ )
434
+ return 0
435
+
436
+
437
+ def undo(ctx: Context) -> int:
438
+ try:
439
+ missing = unrecorded_path(ctx)
440
+ except OSError as exc:
441
+ print(f"ERROR: cannot check {exc.filename}: {exc.strerror}; nothing was changed", file=sys.stderr)
442
+ print("HINT: give this account access to the folders on that path, then undo again", file=sys.stderr)
443
+ return 3
444
+ if missing is not None:
445
+ # A second undo, or an undo before any setup: nothing is recorded, so nothing is
446
+ # to be reversed, and nothing is written.
447
+ print(
448
+ f"NOTHING TO UNDO: no setup is recorded for {ctx.label} (looked for {missing}); "
449
+ "nothing was changed"
450
+ )
451
+ return 0
452
+ record = read_record(ctx)
453
+ code, out, err = run(guided_command(ctx, "--rollback", *(("--apply",) if ctx.apply else ())))
454
+ destination = str(record["destination"]) if record else str(ctx.skills)
455
+ readback = re.search(r"^ROLLBACK_READBACK: removed=([^;\n]*);", out, re.MULTILINE)
456
+ if code == 3 and readback and re.search(r"; profile=preserved$", out, re.MULTILINE):
457
+ # The instruction file changed since the setup: the links are gone, the file stays.
458
+ removed = [name for name in readback.group(1).split(",") if name not in ("", "none")]
459
+ print(f"PARTLY UNDONE: nobrainer-tech-flow for {ctx.label}")
460
+ print(f"SKILLS: {len(removed)} unlinked from {destination}")
461
+ for line in unique(out.splitlines()):
462
+ if UNDO_NOTE.match(line):
463
+ print(line)
464
+ print(f"NEXT: restart {ctx.label} to drop the skills.")
465
+ return code
466
+ if code:
467
+ print(translate(out), end="")
468
+ print(translate(err), end="", file=sys.stderr)
469
+ return code
470
+ created = list(record.get("created_skills", [])) if record else []
471
+ directories = list(record.get("created_dirs", [])) if record else []
472
+ profile = (record or {}).get("profile") or {}
473
+ target = profile.get("target")
474
+ backup = profile.get("backup")
475
+ if not ctx.apply:
476
+ print(f"UNDO_PLAN: unlink {len(created)} skills from {destination}")
477
+ if re.search(r"^PRESERVED: personalization changed since setup", out, re.MULTILINE):
478
+ # The file stays as it is, and so do the folders; the note below says why.
479
+ target, directories = None, []
480
+ kept = re.search(r"^INSTRUCTIONS_KEPT: (.+)$", out, re.MULTILINE)
481
+ if kept:
482
+ print(f"INSTRUCTIONS: {kept.group(1)}")
483
+ elif target and backup:
484
+ print(
485
+ f"INSTRUCTIONS: {target}: put the managed block back as it was before the setup "
486
+ f"(from {backup}); later edits outside the block stay"
487
+ )
488
+ elif target:
489
+ print(f"INSTRUCTIONS: {target}: remove the managed block")
490
+ for folder in sorted(directories, key=lambda item: len(Path(item).parts), reverse=True):
491
+ print(f"FOLDER: {folder}: removed if it is still empty; the setup created it")
492
+ for line in unique(out.splitlines()):
493
+ if UNDO_NOTE.match(line):
494
+ print(line)
495
+ note = ctx.variable_note()
496
+ if note:
497
+ print(note)
498
+ print(f"NO_CHANGES: preview only; undo with: {ctx.command('--undo', '--apply')}")
499
+ return 0
500
+ removed = [name for name in readback.group(1).split(",") if name not in ("", "none")] if readback else []
501
+ print(f"UNDONE: nobrainer-tech-flow for {ctx.label}")
502
+ print(f"SKILLS: {len(removed)} unlinked from {destination}")
503
+ kept = re.search(r"^INSTRUCTIONS_KEPT: (.+)$", out, re.MULTILINE)
504
+ if kept:
505
+ print(f"INSTRUCTIONS: {kept.group(1)}")
506
+ elif target:
507
+ if any(line.startswith("RESTORED: ") for line in out.splitlines()):
508
+ print(f"INSTRUCTIONS: {target} is back as it was before the setup (backup kept: {backup})")
509
+ elif not Path(str(target)).exists():
510
+ print(f"INSTRUCTIONS: {target} removed; the setup had created it")
511
+ else:
512
+ print(f"INSTRUCTIONS: managed block removed from {target}; the rest of the file is kept as it is now")
513
+ for folder in re.findall(r"^REMOVED_DIR: (.+)$", out, re.MULTILINE):
514
+ print(f"FOLDER: removed the empty folder {folder}")
515
+ for line in unique(out.splitlines()):
516
+ if UNDO_NOTE.match(line):
517
+ print(line)
518
+ note = guards_note(ctx)
519
+ if note:
520
+ print(note)
521
+ print(f"NEXT: restart {ctx.label} to drop the skills.")
522
+ return 0
523
+
524
+
525
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
526
+ parser = argparse.ArgumentParser(
527
+ description=(
528
+ "Link every nobrainer-tech-flow skill and add the managed instruction block for "
529
+ "one client. Without --apply this only previews."
530
+ ),
531
+ epilog=(
532
+ "Without Python: scripts/install.sh takes the same flags. Subsets, copy installs, "
533
+ "the shared agents folder and other clients: docs/INSTALL.md."
534
+ ),
535
+ )
536
+ parser.add_argument("--client", choices=tuple(LABELS), required=True)
537
+ parser.add_argument(
538
+ "--home",
539
+ type=Path,
540
+ help="profile home for the documented default locations; when omitted, "
541
+ "CLAUDE_CONFIG_DIR, CODEX_HOME and XDG_CONFIG_HOME are honoured",
542
+ )
543
+ parser.add_argument("--apply", action="store_true", help="write the changes; the default is a preview")
544
+ parser.add_argument(
545
+ "--undo",
546
+ action="store_true",
547
+ help="reverse the setup this command, scripts/install.sh or the guided setup recorded "
548
+ "(add --apply to do it)",
549
+ )
550
+ parser.add_argument(
551
+ "--guards",
552
+ action="store_true",
553
+ help="Claude Code only: add the optional hooks that ask before risky commands and for a "
554
+ "proof line, instead of installing the skills; with --undo remove them (docs/INSTALL.md)",
555
+ )
556
+ return parser.parse_args(argv)
557
+
558
+
559
+ def main(argv: list[str] | None = None) -> int:
560
+ for stream in (sys.stdout, sys.stderr):
561
+ try:
562
+ # Piped output on Windows uses the ANSI code page, which cannot print every
563
+ # character of a path.
564
+ stream.reconfigure(encoding="utf-8", errors="replace")
565
+ except (AttributeError, ValueError):
566
+ pass
567
+ args = parse_args(argv)
568
+ try:
569
+ if args.guards:
570
+ return guards(args)
571
+ guided = load_guided()
572
+ # An explicit --home selects the documented defaults under it and ignores the
573
+ # environment, exactly as the helpers do. They run from the checkout, so a
574
+ # relative path would land inside it.
575
+ variable = None
576
+ if args.home is not None:
577
+ home = Path(os.path.abspath(args.home.expanduser()))
578
+ environ: object = {}
579
+ home_args = ("--home", str(home))
580
+ else:
581
+ if os.name != "nt" and os.environ.get("HOME") == "":
582
+ raise ValueError("HOME is set but empty")
583
+ home, environ, home_args = Path.home(), os.environ, ()
584
+ if not home.is_absolute():
585
+ raise ValueError(f"the home directory must be an absolute path, not {str(home)!r}")
586
+ name = VARIABLES.get(args.client)
587
+ if name and os.environ.get(name):
588
+ variable = f"{name}={os.environ[name]}"
589
+ skills = guided.skills_destination(args.client, home, environ).expanduser().resolve()
590
+ ctx = Context(args.client, args.apply, home, home_args, skills, guided, variable)
591
+ return undo(ctx) if args.undo else install(ctx)
592
+ except Refused as refused:
593
+ return refused.args[0]
594
+ except (OSError, RuntimeError, ValueError) as exc:
595
+ print(f"ERROR: {exc}", file=sys.stderr)
596
+ return 2
597
+
598
+
599
+ if __name__ == "__main__":
600
+ raise SystemExit(main())