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,951 @@
1
+ #!/usr/bin/env python3
2
+ """Install active skills without overwriting an existing installation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import ctypes
8
+ import hashlib
9
+ import os
10
+ import re
11
+ import shutil
12
+ import stat
13
+ import sys
14
+ import tempfile
15
+ from collections.abc import Mapping
16
+ from pathlib import Path
17
+
18
+
19
+ ROOT = Path(__file__).resolve().parents[1]
20
+ SKILLS = ROOT / "skills"
21
+
22
+
23
+ def config_directory(environ: Mapping[str, str], name: str, relevant: bool) -> str:
24
+ """The directory a client variable names, or "" when it is unset or not ours to judge.
25
+
26
+ An empty XDG_CONFIG_HOME counts as unset, as the XDG specification says. Any other
27
+ value is used exactly as written. One that is not absolute (an empty
28
+ CLAUDE_CONFIG_DIR included) is an error for the client that reads the variable,
29
+ since the scripts run from the checkout while the client resolves it against its
30
+ own directory, and is ignored for every other client, which never looks at it.
31
+ """
32
+
33
+ value = environ.get(name)
34
+ if value is None or (value == "" and name != "CLAUDE_CONFIG_DIR"):
35
+ return ""
36
+ if not Path(value).is_absolute():
37
+ if relevant:
38
+ raise ValueError(f"{name} must be an absolute path, not {value!r}")
39
+ return ""
40
+ return value
41
+
42
+
43
+ def client_destinations(
44
+ environ: Mapping[str, str] | None = None,
45
+ home: Path | None = None,
46
+ client: str | None = None,
47
+ ) -> dict[str, Path]:
48
+ """Skill directories per client, honouring the variables the clients read.
49
+
50
+ Claude Code reads CLAUDE_CONFIG_DIR and OpenCode follows XDG_CONFIG_HOME. A bad
51
+ value is an error only for the client that reads it; without ``client`` every
52
+ variable is checked.
53
+ """
54
+
55
+ environ = os.environ if environ is None else environ
56
+ home = Path.home() if home is None else home
57
+ claude = config_directory(environ, "CLAUDE_CONFIG_DIR", client in (None, "claude"))
58
+ xdg = config_directory(environ, "XDG_CONFIG_HOME", client in (None, "opencode"))
59
+ return {
60
+ "claude": (Path(claude) if claude else home / ".claude") / "skills",
61
+ "codex": home / ".agents" / "skills",
62
+ "opencode": (Path(xdg) if xdg else home / ".config") / "opencode" / "skills",
63
+ "copilot": home / ".copilot" / "skills",
64
+ "agents": home / ".agents" / "skills",
65
+ }
66
+
67
+
68
+ try:
69
+ CLIENT_DESTINATIONS = client_destinations()
70
+ except ValueError:
71
+ # main() reports a bad environment value; the module itself must still import.
72
+ CLIENT_DESTINATIONS = client_destinations({})
73
+
74
+ CURATED_SKILLS = frozenset(
75
+ {
76
+ "nobrainer-codex-context",
77
+ "nobrainer-skill-doctor",
78
+ "nobrainer-autoimprove",
79
+ "nobrainer-auto-fine-tune",
80
+ "nobrainer-browser",
81
+ "nobrainer-build",
82
+ "nobrainer-security",
83
+ "nobrainer-decide",
84
+ "nobrainer-dispatcher",
85
+ "nobrainer-rca",
86
+ "nobrainer-review",
87
+ "nobrainer-research",
88
+ "nobrainer-writing",
89
+ "nobrainer-sessions",
90
+ "nobrainer-spec-driven-development",
91
+ "nobrainer-tech-flow",
92
+ "nobrainer-wiki",
93
+ "nobrainer-team",
94
+ }
95
+ )
96
+
97
+ # Reviewed predecessor names and migration candidates. A legacy symlink is
98
+ # migratable only when its name and target both match this table and this exact
99
+ # checkout. Foreign targets are never touched; only retired names may coexist.
100
+ LEGACY_TO_CANONICAL = {
101
+ "nobrainer-ultra": "nobrainer-tech-flow",
102
+ "add-gitleaks": "nobrainer-review",
103
+ "agent-browser": "nobrainer-browser",
104
+ "agents-restraint": "nobrainer-tech-flow",
105
+ "codex-in-claude-code": "nobrainer-tech-flow",
106
+ "code-autoresearch": "nobrainer-autoimprove",
107
+ "deep-audit": "nobrainer-review",
108
+ "deep-autoreview": "nobrainer-review",
109
+ "deep-autoresearch": "nobrainer-autoimprove",
110
+ "deep-bugs-finder": "nobrainer-review",
111
+ "deep-code-review": "nobrainer-review",
112
+ "deep-decide": "nobrainer-decide",
113
+ "dispatching-parallel-agents": "nobrainer-dispatcher",
114
+ "deep-rca": "nobrainer-rca",
115
+ "engineering-standards": "nobrainer-build",
116
+ "karpathy-auto-improver": "nobrainer-autoimprove",
117
+ "karpathy-llm-wiki": "nobrainer-wiki",
118
+ "llm-wiki": "nobrainer-wiki",
119
+ "nb-add": "nobrainer-wiki",
120
+ "nb-flow": "nobrainer-tech-flow",
121
+ "nb-dispatcher": "nobrainer-dispatcher",
122
+ "nb-get": "nobrainer-wiki",
123
+ "nb-multi": "nobrainer-sessions",
124
+ "nb-tidy": "nobrainer-wiki",
125
+ "nb-workflow": "nobrainer-tech-flow",
126
+ "nb-write": "nobrainer-writing",
127
+ "nobrainer-autopilot": "nobrainer-tech-flow",
128
+ "nobrainer-browser": "nobrainer-browser",
129
+ "nobrainer-capture-lesson": "nobrainer-autoimprove",
130
+ "nobrainer-continuous-improvement": "nobrainer-autoimprove",
131
+ "nobrainer-skill-browser": "nobrainer-team",
132
+ "nobrainer-simplifier": "nobrainer-build",
133
+ "nobrainer-style": "nobrainer-writing",
134
+ "nobrainer-human-like": "nobrainer-writing",
135
+ "nobrainer-starter": "nobrainer-tech-flow",
136
+ "nobrainer-memory": "nobrainer-wiki",
137
+ "nobrainer-memory-memsearch": "nobrainer-wiki",
138
+ "nobrainer-npm-secure": "nobrainer-security",
139
+ "nobrainer-reddit": "nobrainer-tech-flow",
140
+ "nobrainer-team-builder": "nobrainer-team",
141
+ "nobrainer-ultracode-workflow": "nobrainer-tech-flow",
142
+ "nobrainer-wiki-add": "nobrainer-wiki",
143
+ "nobrainer-wiki-get": "nobrainer-wiki",
144
+ "nobrainer-wiki-tidy": "nobrainer-wiki",
145
+ "playwright-cli": "nobrainer-browser",
146
+ "security-review": "nobrainer-security",
147
+ "session-handoff": "nobrainer-sessions",
148
+ "wiki-add": "nobrainer-wiki",
149
+ "wiki-get": "nobrainer-wiki",
150
+ "wiki-tidy": "nobrainer-wiki",
151
+ }
152
+ # Reddit integration was removed, not absorbed into a canonical skill. Keep its
153
+ # owned-link migration, but a different source may legitimately use this name.
154
+ RETIRED_LEGACY = frozenset({"nobrainer-reddit"})
155
+ UNMAPPED_LEGACY = frozenset({"nobrainer-fast-audit"})
156
+
157
+
158
+ EntryFingerprint = tuple[int, int, int, str | None]
159
+ TreeManifest = tuple[tuple[str, str, int, str], ...]
160
+
161
+
162
+ def entry_fingerprint(target: Path) -> EntryFingerprint | None:
163
+ """Fingerprint one directory entry without following a symlink."""
164
+
165
+ try:
166
+ metadata = target.lstat()
167
+ linked = os.readlink(target) if stat.S_ISLNK(metadata.st_mode) else None
168
+ except OSError:
169
+ return None
170
+ return metadata.st_dev, metadata.st_ino, metadata.st_mode, linked
171
+
172
+
173
+ JUNK_DIRECTORIES = frozenset({"__pycache__"})
174
+ JUNK_FILES = frozenset({".DS_Store", "Thumbs.db"})
175
+
176
+
177
+ def is_junk(name: str, is_directory: bool) -> bool:
178
+ """Bytecode caches and file-manager litter: never part of a skill, so never compared.
179
+
180
+ Only these exact names count. A loose ``.pyc`` next to a script is importable and
181
+ changes what the skill runs, so it is a difference like any other file.
182
+ """
183
+
184
+ return name in (JUNK_DIRECTORIES if is_directory else JUNK_FILES)
185
+
186
+
187
+ def file_digest(path: Path) -> str:
188
+ """SHA-256 in bounded chunks, so a huge foreign file cannot exhaust memory."""
189
+
190
+ digest = hashlib.sha256()
191
+ with path.open("rb") as handle:
192
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
193
+ digest.update(chunk)
194
+ return digest.hexdigest()
195
+
196
+
197
+ def tree_manifest(root: Path, hash_files: bool = True) -> TreeManifest:
198
+ """Fingerprint a tree without following links or accepting special files.
199
+
200
+ With ``hash_files=False`` a file is identified by its size only, which is enough
201
+ to tell two different trees apart without reading a byte of either.
202
+ """
203
+
204
+ entries: list[tuple[str, str, int, str]] = []
205
+ for current, directories, files in os.walk(root, topdown=True, followlinks=False):
206
+ current_path = Path(current)
207
+ traversable: list[str] = []
208
+ for name in sorted(directories):
209
+ if is_junk(name, True):
210
+ continue
211
+ path = current_path / name
212
+ relative = path.relative_to(root).as_posix()
213
+ metadata = path.lstat()
214
+ if stat.S_ISLNK(metadata.st_mode):
215
+ entries.append((relative, "symlink", 0, os.readlink(path)))
216
+ elif stat.S_ISDIR(metadata.st_mode):
217
+ entries.append(
218
+ (relative, "directory", stat.S_IMODE(metadata.st_mode), "")
219
+ )
220
+ traversable.append(name)
221
+ else:
222
+ raise RuntimeError(f"unsupported source entry: {path}")
223
+ directories[:] = traversable
224
+
225
+ for name in sorted(files):
226
+ if is_junk(name, False):
227
+ continue
228
+ path = current_path / name
229
+ relative = path.relative_to(root).as_posix()
230
+ metadata = path.lstat()
231
+ if stat.S_ISLNK(metadata.st_mode):
232
+ entries.append((relative, "symlink", 0, os.readlink(path)))
233
+ elif stat.S_ISREG(metadata.st_mode):
234
+ digest = (
235
+ file_digest(path) if hash_files else f"size:{metadata.st_size}"
236
+ )
237
+ entries.append(
238
+ (relative, "file", stat.S_IMODE(metadata.st_mode), digest)
239
+ )
240
+ else:
241
+ raise RuntimeError(f"unsupported source entry: {path}")
242
+ return tuple(sorted(entries))
243
+
244
+
245
+ def stage_and_publish_copy(
246
+ source: Path, target: Path
247
+ ) -> tuple[EntryFingerprint, TreeManifest, Path]:
248
+ """Verify a private copy, then publish it with native no-replace rename."""
249
+
250
+ stage_parent = Path(
251
+ tempfile.mkdtemp(prefix=".nobrainer-install-", dir=target.parent)
252
+ )
253
+ staged = stage_parent / target.name
254
+ try:
255
+ source_before = tree_manifest(source)
256
+ shutil.copytree(
257
+ source,
258
+ staged,
259
+ symlinks=True,
260
+ ignore=lambda directory, names: [
261
+ name for name in names if is_junk(name, (Path(directory) / name).is_dir())
262
+ ],
263
+ )
264
+ staged_manifest = tree_manifest(staged)
265
+ source_after = tree_manifest(source)
266
+ if source_before != source_after:
267
+ raise RuntimeError(f"source changed while copying: {source}")
268
+ if staged_manifest != source_before:
269
+ raise RuntimeError(f"staged copy verification failed: {source}")
270
+
271
+ expected = entry_fingerprint(staged)
272
+ if expected is None:
273
+ raise RuntimeError(f"staged copy disappeared before publish: {staged}")
274
+ atomic_rename_no_replace(staged, target)
275
+ except Exception as exc:
276
+ raise RuntimeError(
277
+ f"copy staging preserved for manual recovery at {stage_parent}: {exc}"
278
+ ) from exc
279
+ return expected, source_before, stage_parent
280
+
281
+
282
+ def stage_and_publish_symlink(
283
+ source: Path, target: Path
284
+ ) -> tuple[EntryFingerprint, Path]:
285
+ """Fingerprint a private symlink, then publish that exact entry atomically."""
286
+
287
+ stage_parent = Path(
288
+ tempfile.mkdtemp(prefix=".nobrainer-install-", dir=target.parent)
289
+ )
290
+ staged = stage_parent / target.name
291
+ try:
292
+ os.symlink(source, staged, target_is_directory=True)
293
+ expected = entry_fingerprint(staged)
294
+ if expected is None:
295
+ raise RuntimeError(f"staged symlink disappeared before publish: {staged}")
296
+ atomic_rename_no_replace(staged, target)
297
+ except Exception as exc:
298
+ raise RuntimeError(
299
+ f"symlink staging preserved for manual recovery at {stage_parent}: {exc}"
300
+ ) from exc
301
+ return expected, stage_parent
302
+
303
+
304
+ def remove_created_entry(
305
+ target: Path,
306
+ expected: EntryFingerprint,
307
+ expected_manifest: TreeManifest | None = None,
308
+ ) -> None:
309
+ """Move a created entry out of the public target and preserve it for recovery."""
310
+
311
+ current_fingerprint = entry_fingerprint(target)
312
+ if current_fingerprint is None:
313
+ return
314
+ if current_fingerprint == expected and expected_manifest is not None:
315
+ try:
316
+ current_manifest = tree_manifest(target)
317
+ except (OSError, RuntimeError) as exc:
318
+ raise RuntimeError(
319
+ f"created copy could not be verified; target preserved at {target}"
320
+ ) from exc
321
+ if current_manifest != expected_manifest:
322
+ raise RuntimeError(
323
+ f"created copy content changed; target preserved at {target}"
324
+ )
325
+
326
+ claim_dir = Path(
327
+ tempfile.mkdtemp(prefix=".nobrainer-rollback-", dir=target.parent)
328
+ )
329
+ claim = claim_dir / target.name
330
+ try:
331
+ target.rename(claim)
332
+ except OSError as exc:
333
+ try:
334
+ claim_dir.rmdir()
335
+ except OSError:
336
+ pass
337
+ if entry_fingerprint(target) is None:
338
+ return
339
+ raise RuntimeError(f"created target could not be claimed: {target}") from exc
340
+
341
+ claimed = entry_fingerprint(claim)
342
+ if claimed != expected:
343
+ try:
344
+ restore_claim(target, claim)
345
+ except RuntimeError as exc:
346
+ raise RuntimeError(
347
+ f"ownership changed after creation; foreign replacement preserved "
348
+ f"at {claim}; {exc}"
349
+ ) from exc
350
+ raise RuntimeError(
351
+ f"ownership changed after creation; foreign target restored at {target}"
352
+ )
353
+
354
+ if expected_manifest is not None:
355
+ try:
356
+ claimed_manifest = tree_manifest(claim)
357
+ except (OSError, RuntimeError) as exc:
358
+ try:
359
+ restore_claim(target, claim, expected)
360
+ except RuntimeError as restore_exc:
361
+ raise RuntimeError(
362
+ f"created copy changed while claimed; recovery preserved at "
363
+ f"{claim}; {restore_exc}"
364
+ ) from exc
365
+ raise RuntimeError(
366
+ f"created copy changed while claimed; target restored at {target}"
367
+ ) from exc
368
+ if claimed_manifest != expected_manifest:
369
+ try:
370
+ restore_claim(target, claim, expected)
371
+ except RuntimeError as exc:
372
+ raise RuntimeError(
373
+ f"created copy content changed while claimed; recovery preserved "
374
+ f"at {claim}; {exc}"
375
+ ) from exc
376
+ raise RuntimeError(
377
+ f"created copy content changed while claimed; target restored at "
378
+ f"{target}"
379
+ )
380
+
381
+ # There is no portable compare-and-delete primitive for a pathname. Keep the
382
+ # verified private claim instead of risking deletion of a same-user
383
+ # replacement between the last fingerprint and unlink/rmtree.
384
+ raise RuntimeError(
385
+ f"verified rollback claim preserved for manual recovery at {claim}"
386
+ )
387
+
388
+
389
+ def is_exact_legacy_link(target: Path, legacy_name: str) -> bool:
390
+ """Return whether target is the known stale link owned by this checkout."""
391
+
392
+ return legacy_link_snapshot(target, legacy_name) is not None
393
+
394
+
395
+ def legacy_link_snapshot(
396
+ target: Path, legacy_name: str
397
+ ) -> EntryFingerprint | None:
398
+ """Verify and fingerprint the same legacy symlink directory entry."""
399
+
400
+ try:
401
+ before = target.lstat()
402
+ if not stat.S_ISLNK(before.st_mode):
403
+ return None
404
+ linked = os.readlink(target)
405
+ after = target.lstat()
406
+ except OSError:
407
+ return None
408
+
409
+ before_id = before.st_dev, before.st_ino, before.st_mode
410
+ after_id = after.st_dev, after.st_ino, after.st_mode
411
+ if before_id != after_id:
412
+ return None
413
+
414
+ shown = linked
415
+ if os.name == "nt":
416
+ # Windows reports the substitute name, which carries a \\?\ prefix that
417
+ # resolve() keeps, so it would never equal the checkout path.
418
+ if shown.startswith("\\\\?\\UNC\\"):
419
+ shown = "\\\\" + shown[8:]
420
+ elif shown.startswith("\\\\?\\"):
421
+ shown = shown[4:]
422
+ resolved = Path(shown)
423
+ if not resolved.is_absolute():
424
+ resolved = target.parent / resolved
425
+ known_sources = {
426
+ (ROOT / legacy_name).resolve(strict=False),
427
+ (SKILLS / legacy_name).resolve(strict=False),
428
+ }
429
+ try:
430
+ resolved_target = resolved.resolve(strict=False)
431
+ except (OSError, RuntimeError):
432
+ # A symlink loop is never one of the known legacy links.
433
+ return None
434
+ if resolved_target not in known_sources:
435
+ return None
436
+ return before_id[0], before_id[1], before_id[2], linked
437
+
438
+
439
+ def legacy_name_for(target: Path, canonical: Path) -> str | None:
440
+ """Return the known root-level predecessor referenced by a canonical target."""
441
+
442
+ candidates = [canonical.name]
443
+ candidates.extend(
444
+ legacy_name
445
+ for legacy_name, canonical_name in sorted(LEGACY_TO_CANONICAL.items())
446
+ if canonical_name == canonical.name and legacy_name != canonical.name
447
+ )
448
+ canonical_source = canonical.resolve(strict=False)
449
+ for legacy_name in candidates:
450
+ old_source = (ROOT / legacy_name).resolve(strict=False)
451
+ if old_source != canonical_source and is_exact_legacy_link(
452
+ target, legacy_name
453
+ ):
454
+ return legacy_name
455
+ return None
456
+
457
+
458
+ def claim_legacy_link(
459
+ target: Path, legacy_name: str
460
+ ) -> tuple[Path, EntryFingerprint]:
461
+ """Atomically move a candidate aside, then verify ownership before deletion."""
462
+
463
+ expected = legacy_link_snapshot(target, legacy_name)
464
+ if expected is None:
465
+ raise RuntimeError(f"legacy link changed before claim: {target}")
466
+
467
+ claim_dir = Path(
468
+ tempfile.mkdtemp(prefix=".nobrainer-migration-", dir=target.parent)
469
+ )
470
+ claim = claim_dir / target.name
471
+ try:
472
+ target.rename(claim)
473
+ except OSError as exc:
474
+ claim_dir.rmdir()
475
+ raise RuntimeError(f"legacy link changed after preflight: {target}") from exc
476
+
477
+ claimed = entry_fingerprint(claim)
478
+
479
+ # Compare the moved directory entry itself. Re-resolving a relative link
480
+ # from claim.parent would change its base and reject a valid legacy link.
481
+ if claimed == expected:
482
+ return claim, expected
483
+
484
+ try:
485
+ restore_claim(target, claim)
486
+ except RuntimeError as exc:
487
+ raise RuntimeError(
488
+ f"legacy link changed after preflight; {exc}"
489
+ ) from exc
490
+ raise RuntimeError(
491
+ f"legacy link changed after preflight; replacement restored at {target}"
492
+ )
493
+
494
+
495
+ def atomic_rename_no_replace(source: Path, target: Path) -> None:
496
+ """Rename one entry only when target is absent, using the native primitive."""
497
+
498
+ if os.name == "nt":
499
+ # Windows os.rename already refuses to replace an existing target, and
500
+ # ctypes.CDLL(None) is not supported there.
501
+ os.rename(source, target)
502
+ return
503
+
504
+ libc = ctypes.CDLL(None, use_errno=True)
505
+ source_bytes = os.fsencode(source)
506
+ target_bytes = os.fsencode(target)
507
+
508
+ if sys.platform == "darwin":
509
+ renamex = getattr(libc, "renamex_np", None)
510
+ if renamex is None:
511
+ raise RuntimeError("atomic no-replace rename is unavailable")
512
+ renamex.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
513
+ renamex.restype = ctypes.c_int
514
+ result = renamex(source_bytes, target_bytes, 0x00000004) # RENAME_EXCL
515
+ elif sys.platform.startswith("linux"):
516
+ renameat2 = getattr(libc, "renameat2", None)
517
+ if renameat2 is None:
518
+ raise RuntimeError("atomic no-replace rename is unavailable")
519
+ renameat2.argtypes = [
520
+ ctypes.c_int,
521
+ ctypes.c_char_p,
522
+ ctypes.c_int,
523
+ ctypes.c_char_p,
524
+ ctypes.c_uint,
525
+ ]
526
+ renameat2.restype = ctypes.c_int
527
+ result = renameat2(
528
+ -100, source_bytes, -100, target_bytes, 0x00000001
529
+ ) # AT_FDCWD, RENAME_NOREPLACE
530
+ else:
531
+ raise RuntimeError("atomic no-replace rename is unavailable")
532
+
533
+ if result != 0:
534
+ error_number = ctypes.get_errno()
535
+ raise OSError(error_number, os.strerror(error_number), str(target))
536
+
537
+
538
+ def restore_claim(
539
+ target: Path,
540
+ claim: Path,
541
+ expected: EntryFingerprint | None = None,
542
+ ) -> None:
543
+ """Restore a claimed entry atomically without replacing a new target."""
544
+
545
+ fingerprint = entry_fingerprint(claim)
546
+ if fingerprint is None:
547
+ raise RuntimeError(f"missing or unreadable claim preserved at {claim}")
548
+ if expected is not None and fingerprint != expected:
549
+ raise RuntimeError(f"claim ownership changed; replacement preserved at {claim}")
550
+ try:
551
+ # Move the entry back instead of recreating and deleting a duplicate.
552
+ # This preserves relative symlink text and has no deletion race.
553
+ atomic_rename_no_replace(claim, target)
554
+ except OSError as exc:
555
+ raise RuntimeError(
556
+ f"replacement preserved at {claim}; restore blocked by {target}"
557
+ ) from exc
558
+ except RuntimeError as exc:
559
+ raise RuntimeError(f"replacement preserved at {claim}; {exc}") from exc
560
+
561
+ if entry_fingerprint(target) != fingerprint:
562
+ raise RuntimeError(
563
+ f"restored entry changed concurrently; inspect preserved target {target}"
564
+ )
565
+ try:
566
+ claim.parent.rmdir()
567
+ except OSError as exc:
568
+ raise RuntimeError(
569
+ f"replacement restored at {target}; claim directory remains at {claim.parent}"
570
+ ) from exc
571
+
572
+
573
+ def available_skills() -> dict[str, Path]:
574
+ return {
575
+ path.parent.name: path.parent
576
+ for path in sorted(SKILLS.glob("*/SKILL.md"))
577
+ }
578
+
579
+
580
+ def existing_state(target: Path, source: Path, mode: str) -> str:
581
+ if not target.exists() and not target.is_symlink():
582
+ return "missing"
583
+ if target.is_symlink():
584
+ try:
585
+ if mode == "symlink" and target.resolve(strict=True) == source.resolve(
586
+ strict=True
587
+ ):
588
+ return "current"
589
+ except (OSError, RuntimeError):
590
+ # Dangling links and symlink loops are conflicts, not crashes.
591
+ pass
592
+ if legacy_name_for(target, source) is not None:
593
+ return "legacy"
594
+ elif mode == "copy" and target.is_dir():
595
+ try:
596
+ # Compare names, kinds and sizes first: a foreign directory (possibly with
597
+ # gigabytes in it) is then rejected without reading any file.
598
+ if tree_manifest(target, hash_files=False) == tree_manifest(
599
+ source, hash_files=False
600
+ ) and tree_manifest(target) == tree_manifest(source):
601
+ return "current"
602
+ except (OSError, RuntimeError):
603
+ pass
604
+ return "conflict"
605
+
606
+
607
+ def destination_problem(destination: Path) -> str | None:
608
+ """Explain why a destination cannot hold skills, before anything is planned."""
609
+
610
+ for candidate in (destination, *destination.parents):
611
+ if candidate.exists():
612
+ if not candidate.is_dir():
613
+ return f"destination is not a directory: {candidate}"
614
+ return None
615
+ if candidate.is_symlink():
616
+ # exists() is False for a dangling link and for a loop, and since Python 3.13
617
+ # resolve() no longer raises on a loop.
618
+ return f"destination is a dangling or looping link: {candidate}"
619
+ return None
620
+
621
+
622
+ def linked_skills(name: str, catalogue: dict[str, Path]) -> list[str]:
623
+ """Other skills that a skill links into with relative Markdown links."""
624
+
625
+ found: list[str] = []
626
+ for markdown in sorted(catalogue[name].rglob("*.md")):
627
+ text = markdown.read_text(encoding="utf-8")
628
+ for match in re.finditer(r"\]\((\.\./[^)#\s]+)", text):
629
+ target = os.path.normpath(markdown.parent / match.group(1))
630
+ try:
631
+ relative = Path(target).relative_to(SKILLS)
632
+ except ValueError:
633
+ continue
634
+ other = relative.parts[0] if relative.parts else ""
635
+ if other and other != name and other in catalogue and other not in found:
636
+ found.append(other)
637
+ return found
638
+
639
+
640
+ def unmet_references(
641
+ names: list[str], catalogue: dict[str, Path], destination: Path
642
+ ) -> list[tuple[str, str]]:
643
+ """Cross-skill links from selected skills to skills that will be absent."""
644
+
645
+ present = set(names) | {
646
+ name for name in catalogue if (destination / name / "SKILL.md").is_file()
647
+ }
648
+ return [
649
+ (name, other)
650
+ for name in names
651
+ for other in linked_skills(name, catalogue)
652
+ if other not in present
653
+ ]
654
+
655
+
656
+ def parse_args() -> argparse.Namespace:
657
+ parser = argparse.ArgumentParser(
658
+ description="Dry-run or install NoBrainer skills into one client directory."
659
+ )
660
+ parser.add_argument("--client", choices=sorted(CLIENT_DESTINATIONS), required=True)
661
+ parser.add_argument(
662
+ "--dest",
663
+ type=Path,
664
+ help="Override the client destination (useful for controlled tests).",
665
+ )
666
+ parser.add_argument("--mode", choices=("symlink", "copy"), default="symlink")
667
+ parser.add_argument(
668
+ "--skill",
669
+ action="append",
670
+ dest="skills",
671
+ help="Install only this skill; repeat for several. Default: all curated skills.",
672
+ )
673
+ parser.add_argument(
674
+ "--apply",
675
+ action="store_true",
676
+ help="Perform writes. Without this flag the command is a dry-run.",
677
+ )
678
+ parser.add_argument(
679
+ "--migrate-legacy",
680
+ action="store_true",
681
+ help=(
682
+ "Migrate only exact stale symlinks from this checkout's previous "
683
+ "root-level layout and renamed aliases."
684
+ ),
685
+ )
686
+ return parser.parse_args()
687
+
688
+
689
+ def main() -> int:
690
+ for stream in (sys.stdout, sys.stderr):
691
+ try:
692
+ # Piped output on Windows uses the ANSI code page, which cannot print every
693
+ # character of a user's paths.
694
+ stream.reconfigure(encoding="utf-8", errors="replace")
695
+ except (AttributeError, ValueError):
696
+ pass
697
+ args = parse_args()
698
+ catalogue = available_skills()
699
+ actual_inventory = set(catalogue)
700
+ if actual_inventory != CURATED_SKILLS:
701
+ missing = sorted(CURATED_SKILLS - actual_inventory)
702
+ extra = sorted(actual_inventory - CURATED_SKILLS)
703
+ print(
704
+ f"ERROR: curated skill inventory drift: missing={missing}, extra={extra}",
705
+ file=sys.stderr,
706
+ )
707
+ return 2
708
+
709
+ requested = sorted(set(args.skills)) if args.skills else sorted(CURATED_SKILLS)
710
+ unknown = sorted(set(requested) - CURATED_SKILLS)
711
+ if unknown:
712
+ print(f"ERROR: unknown active skill(s): {', '.join(unknown)}", file=sys.stderr)
713
+ return 2
714
+
715
+ try:
716
+ destination = (
717
+ args.dest or client_destinations(client=args.client)[args.client]
718
+ ).expanduser().resolve()
719
+ problem = destination_problem(destination)
720
+ except (OSError, RuntimeError, ValueError) as exc:
721
+ # Symlink loops, unreadable parents and unusable environment variables.
722
+ print(f"ERROR: cannot use the destination: {exc}", file=sys.stderr)
723
+ return 2
724
+ if problem:
725
+ print(f"ERROR: {problem}", file=sys.stderr)
726
+ return 2
727
+ plan: list[tuple[str, Path, Path, str]] = []
728
+ conflicts: list[Path] = []
729
+ for name in requested:
730
+ source = catalogue[name]
731
+ target = destination / name
732
+ state = existing_state(target, source, args.mode)
733
+ plan.append((name, source, target, state))
734
+ if state == "conflict" or (state == "legacy" and not args.migrate_legacy):
735
+ conflicts.append(target)
736
+
737
+ alias_plan: list[tuple[str, str, Path, str]] = []
738
+ for legacy_name, canonical_name in sorted(LEGACY_TO_CANONICAL.items()):
739
+ if legacy_name == canonical_name or canonical_name not in requested:
740
+ continue
741
+ target = destination / legacy_name
742
+ if not target.exists() and not target.is_symlink():
743
+ continue
744
+ state = (
745
+ "legacy"
746
+ if is_exact_legacy_link(target, legacy_name)
747
+ else "preserved"
748
+ if legacy_name in RETIRED_LEGACY
749
+ else "conflict"
750
+ )
751
+ alias_plan.append((legacy_name, canonical_name, target, state))
752
+ if state == "conflict" or (state == "legacy" and not args.migrate_legacy):
753
+ conflicts.append(target)
754
+
755
+ unmapped_plan: list[Path] = []
756
+ for legacy_name in sorted(UNMAPPED_LEGACY):
757
+ target = destination / legacy_name
758
+ if target.exists() or target.is_symlink():
759
+ unmapped_plan.append(target)
760
+ conflicts.append(target)
761
+
762
+ for name, source, target, state in plan:
763
+ action = (
764
+ "KEEP"
765
+ if state == "current"
766
+ else "CONFLICT"
767
+ if state == "conflict"
768
+ else "MIGRATE"
769
+ if state == "legacy" and args.migrate_legacy
770
+ else "LEGACY"
771
+ if state == "legacy"
772
+ else args.mode.upper()
773
+ )
774
+ print(f"{action}: {name}: {source} -> {target}")
775
+
776
+ for legacy_name, canonical_name, target, state in alias_plan:
777
+ if state == "preserved":
778
+ print(
779
+ f"PRESERVED_FOREIGN: {legacy_name}: retired from nobrainer-tech-flow; "
780
+ f"left unchanged: {target}"
781
+ )
782
+ continue
783
+ action = (
784
+ "MIGRATE_ALIAS"
785
+ if state == "legacy" and args.migrate_legacy
786
+ else "LEGACY_ALIAS"
787
+ if state == "legacy"
788
+ else "CONFLICT_ALIAS"
789
+ )
790
+ print(f"{action}: {legacy_name} -> {canonical_name}: {target}")
791
+
792
+ for name, other in unmet_references(requested, catalogue, destination):
793
+ print(
794
+ f"NOTE: {name} links to {other}, which is not selected or installed; "
795
+ f"add --skill {other} to include it"
796
+ )
797
+
798
+ for target in unmapped_plan:
799
+ print(
800
+ "UNMAPPED_CONFLICT: "
801
+ f"{target.name}: preserve and audit before selecting a canonical owner: "
802
+ f"{target}"
803
+ )
804
+
805
+ if conflicts:
806
+ has_unknown_conflict = (
807
+ bool(unmapped_plan)
808
+ or any(state == "conflict" for _, _, _, state in plan)
809
+ or any(state == "conflict" for _, _, _, state in alias_plan)
810
+ )
811
+ has_migratable_legacy = any(
812
+ state == "legacy" for _, _, _, state in plan
813
+ ) or any(state == "legacy" for _, _, _, state in alias_plan)
814
+ if has_migratable_legacy and not args.migrate_legacy and not has_unknown_conflict:
815
+ print(
816
+ "ERROR: legacy symlink detected; rerun with "
817
+ "--migrate-legacy --apply",
818
+ file=sys.stderr,
819
+ )
820
+ else:
821
+ print("ERROR: refusing to overwrite existing targets", file=sys.stderr)
822
+ return 3
823
+ if not args.apply:
824
+ suffix = (
825
+ " with --migrate-legacy --apply"
826
+ if any(state == "legacy" for _, _, _, state in plan)
827
+ or any(state == "legacy" for _, _, _, state in alias_plan)
828
+ else " with --apply"
829
+ )
830
+ print(f"DRY_RUN: no files changed; rerun{suffix} to install")
831
+ return 0
832
+
833
+ try:
834
+ destination.mkdir(parents=True, exist_ok=True)
835
+ except OSError as exc:
836
+ print(f"ERROR: cannot create destination {destination}: {exc}", file=sys.stderr)
837
+ return 2
838
+ created: list[tuple[Path, EntryFingerprint, TreeManifest | None]] = []
839
+ migrated: list[tuple[Path, Path, str, EntryFingerprint]] = []
840
+ copied_manifests: dict[Path, TreeManifest] = {}
841
+ try:
842
+ for _, source, target, state in plan:
843
+ if state == "current" and args.mode == "copy":
844
+ copied_manifests[target] = tree_manifest(source)
845
+ for legacy_name, _, target, state in alias_plan:
846
+ if state != "legacy":
847
+ continue
848
+ claim, expected = claim_legacy_link(target, legacy_name)
849
+ migrated.append((target, claim, legacy_name, expected))
850
+
851
+ for _, source, target, state in plan:
852
+ if state == "current":
853
+ continue
854
+ if state == "legacy":
855
+ legacy_name = legacy_name_for(target, source)
856
+ if legacy_name is None:
857
+ raise RuntimeError(f"legacy target changed after preflight: {target}")
858
+ claim, expected = claim_legacy_link(target, legacy_name)
859
+ migrated.append((target, claim, legacy_name, expected))
860
+ if args.mode == "symlink":
861
+ expected, stage_parent = stage_and_publish_symlink(source, target)
862
+ created.append((target, expected, None))
863
+ if entry_fingerprint(target) != expected:
864
+ raise RuntimeError(
865
+ f"published symlink changed before readback: {target}"
866
+ )
867
+ try:
868
+ stage_parent.rmdir()
869
+ except OSError as exc:
870
+ raise RuntimeError(
871
+ f"published symlink is complete but private staging directory "
872
+ f"could not be removed: {stage_parent}"
873
+ ) from exc
874
+ else:
875
+ expected, manifest, stage_parent = stage_and_publish_copy(
876
+ source, target
877
+ )
878
+ created.append((target, expected, manifest))
879
+ copied_manifests[target] = manifest
880
+ if entry_fingerprint(target) != expected:
881
+ raise RuntimeError(
882
+ f"published target changed before readback: {target}"
883
+ )
884
+ try:
885
+ stage_parent.rmdir()
886
+ except OSError as exc:
887
+ raise RuntimeError(
888
+ f"published copy is complete but private staging directory "
889
+ f"could not be removed: {stage_parent}"
890
+ ) from exc
891
+
892
+ for _, source, target, _ in plan:
893
+ if not (target / "SKILL.md").is_file():
894
+ raise RuntimeError(f"readback failed for {target}")
895
+ if (
896
+ args.mode == "symlink"
897
+ and target.resolve(strict=True) != source.resolve(strict=True)
898
+ ):
899
+ raise RuntimeError(f"symlink readback mismatch for {target}")
900
+ if args.mode == "copy" and tree_manifest(target) != copied_manifests.get(
901
+ target
902
+ ):
903
+ raise RuntimeError(f"copy readback mismatch for {target}")
904
+ except Exception as exc:
905
+ rollback_errors: list[str] = []
906
+ for target, fingerprint, manifest in reversed(created):
907
+ try:
908
+ remove_created_entry(target, fingerprint, manifest)
909
+ except (OSError, RuntimeError) as rollback_exc:
910
+ rollback_errors.append(f"{target}: {rollback_exc}")
911
+ for target, claim, _, expected in reversed(migrated):
912
+ try:
913
+ restore_claim(target, claim, expected)
914
+ except (OSError, RuntimeError) as rollback_exc:
915
+ rollback_errors.append(
916
+ f"{target} (legacy preserved at {claim}): {rollback_exc}"
917
+ )
918
+ if rollback_errors:
919
+ print(
920
+ "ERROR: installation failed and rollback was incomplete: "
921
+ f"{exc}; leftovers: {'; '.join(rollback_errors)}",
922
+ file=sys.stderr,
923
+ )
924
+ else:
925
+ print(f"ERROR: installation rolled back: {exc}", file=sys.stderr)
926
+ return 4
927
+
928
+ changed_backups: list[str] = []
929
+ for _, claim, _, expected in migrated:
930
+ if entry_fingerprint(claim) == expected:
931
+ print(f"BACKUP_PRESERVED: migrated legacy link: {claim}")
932
+ else:
933
+ changed_backups.append(str(claim))
934
+ if changed_backups:
935
+ print(
936
+ "WARNING: install succeeded, but migration backup ownership changed; "
937
+ "entries were preserved without deletion: " + "; ".join(changed_backups),
938
+ file=sys.stderr,
939
+ )
940
+
941
+ print(
942
+ f"OK: installed {len(created)} skill(s) for {args.client}; "
943
+ f"{len(migrated)} legacy link(s) migrated; "
944
+ f"{len(plan) - len(created)} unchanged; "
945
+ f"{len(migrated)} recovery backup(s) preserved"
946
+ )
947
+ return 0
948
+
949
+
950
+ if __name__ == "__main__":
951
+ raise SystemExit(main())