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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +47 -0
- package/.opencode/INSTALL.md +37 -0
- package/.opencode/plugins/nobrainer-tech-skills.js +66 -0
- package/.pi/extensions/nobrainer-tech-skills.js +64 -0
- package/AGENTS.md +204 -0
- package/CLAUDE.md +204 -0
- package/CONTRIBUTING.md +70 -0
- package/GEMINI.md +1 -0
- package/LICENSE +21 -0
- package/README.md +658 -2
- package/SECURITY.md +33 -0
- package/adapters/bootstrap.md +27 -0
- package/assets/nobrainer-tech-logo.svg +1 -0
- package/assets/nobrainer-workflow.svg +155 -0
- package/assets/plugin-logo.png +0 -0
- package/bin/nobrainer-tech-flow.mjs +48 -0
- package/docs/BOUNDED_RUNNER.md +60 -0
- package/docs/CHAT_PLUGINS.md +29 -0
- package/docs/COMPATIBILITY.md +371 -0
- package/docs/INSTALL.md +626 -0
- package/docs/MIGRATION_TO_FLOW.md +117 -0
- package/docs/NAMING.md +39 -0
- package/docs/PRIVACY.md +9 -0
- package/docs/SESSION_RESTART.md +70 -0
- package/docs/SKILL_CURATION.md +124 -0
- package/docs/TESTING.md +232 -0
- package/docs/TRY_IT.md +109 -0
- package/docs/releases/v1.0.0.md +81 -0
- package/docs/releases/v1.1.0.md +85 -0
- package/docs/releases/v1.10.0.md +34 -0
- package/docs/releases/v1.11.0-publication-readback.md +25 -0
- package/docs/releases/v1.11.0.md +42 -0
- package/docs/releases/v1.12.0.md +30 -0
- package/docs/releases/v1.13.0.md +46 -0
- package/docs/releases/v1.14.0.md +43 -0
- package/docs/releases/v1.14.1.md +21 -0
- package/docs/releases/v1.2.0.md +87 -0
- package/docs/releases/v1.2.1.md +92 -0
- package/docs/releases/v1.3.0-publication-readback.md +69 -0
- package/docs/releases/v1.3.0.md +66 -0
- package/docs/releases/v1.3.1-publication-readback.md +76 -0
- package/docs/releases/v1.3.1.md +59 -0
- package/docs/releases/v1.4.0-publication-readback.md +81 -0
- package/docs/releases/v1.4.0.md +43 -0
- package/docs/releases/v1.5.0-publication-readback.md +76 -0
- package/docs/releases/v1.5.0.md +54 -0
- package/docs/releases/v1.6.0.md +64 -0
- package/docs/releases/v1.6.1-publication-readback.md +53 -0
- package/docs/releases/v1.6.1.md +82 -0
- package/docs/releases/v1.7.0.md +42 -0
- package/docs/releases/v1.7.1-publication-readback.md +64 -0
- package/docs/releases/v1.7.1.md +49 -0
- package/docs/releases/v1.8.0.md +63 -0
- package/docs/releases/v1.8.1.md +32 -0
- package/docs/releases/v1.9.0.md +33 -0
- package/docs/releases/v2.0.0-candidate.md +28 -0
- package/docs/releases/v2.0.0.md +53 -0
- package/docs/releases/v2.0.1.md +135 -0
- package/docs/releases/v2.0.2.md +189 -0
- package/docs/releases/v2.1.0.md +64 -0
- package/docs/releases/v2.1.1.md +43 -0
- package/docs/releases/v2.2.0.md +64 -0
- package/docs/releases/v2.2.1.md +11 -0
- package/docs/releases/v2.3.0.md +97 -0
- package/docs/releases/v2.3.1.md +71 -0
- package/docs/releases/v2.3.2.md +242 -0
- package/docs/releases/v2.4.0.md +126 -0
- package/gemini-extension.json +6 -0
- package/hooks/claude-hooks.json +17 -0
- package/hooks/hooks-cursor.json +10 -0
- package/hooks/run-hook.cmd +36 -0
- package/hooks/session-start +38 -0
- package/package.json +69 -4
- package/plugin.json +51 -0
- package/scripts/build_plugin.py +50 -0
- package/scripts/check_flow_update.py +18 -0
- package/scripts/check_public_commit.py +86 -0
- package/scripts/fetch_public_refs.py +69 -0
- package/scripts/install.py +600 -0
- package/scripts/install.sh +1392 -0
- package/scripts/install_guards.py +279 -0
- package/scripts/install_personalization.py +527 -0
- package/scripts/install_skills.py +951 -0
- package/scripts/npm_install.py +85 -0
- package/scripts/recommend_flow_setup.py +1522 -0
- package/scripts/validate_skills.py +465 -0
- package/skills/nobrainer-auto-fine-tune/SKILL.md +79 -0
- package/skills/nobrainer-auto-fine-tune/references/capacity-audit.md +49 -0
- package/skills/nobrainer-autoimprove/SKILL.md +212 -0
- package/skills/nobrainer-autoimprove/scripts/count_words.py +22 -0
- package/skills/nobrainer-browser/SKILL.md +234 -0
- package/skills/nobrainer-browser/references/cdp-profile-restart.md +93 -0
- package/skills/nobrainer-build/SKILL.md +173 -0
- package/skills/nobrainer-codex-context/SKILL.md +111 -0
- package/skills/nobrainer-codex-context/scripts/codex_context.py +175 -0
- package/skills/nobrainer-decide/SKILL.md +198 -0
- package/skills/nobrainer-dispatcher/SKILL.md +256 -0
- package/skills/nobrainer-dispatcher/agents/openai.yaml +4 -0
- package/skills/nobrainer-rca/SKILL.md +163 -0
- package/skills/nobrainer-research/SKILL.md +131 -0
- package/skills/nobrainer-review/SKILL.md +195 -0
- package/skills/nobrainer-security/SKILL.md +166 -0
- package/skills/nobrainer-sessions/SKILL.md +223 -0
- package/skills/nobrainer-sessions/references/evidence-receipt.md +55 -0
- package/skills/nobrainer-sessions/references/protocol.md +337 -0
- package/skills/nobrainer-sessions/references/session-restart.md +255 -0
- package/skills/nobrainer-sessions/scripts/evidence_receipt.py +373 -0
- package/skills/nobrainer-sessions/scripts/restart_gate.py +140 -0
- package/skills/nobrainer-sessions/scripts/run_bounded.py +390 -0
- package/skills/nobrainer-sessions/scripts/session_title.py +43 -0
- package/skills/nobrainer-skill-doctor/SKILL.md +149 -0
- package/skills/nobrainer-skill-doctor/references/astra.md +19 -0
- package/skills/nobrainer-skill-doctor/scripts/audit.py +100 -0
- package/skills/nobrainer-skill-doctor/scripts/context_candidates.py +133 -0
- package/skills/nobrainer-spec-driven-development/SKILL.md +132 -0
- package/skills/nobrainer-spec-driven-development/references/spec-template.md +114 -0
- package/skills/nobrainer-team/SKILL.md +165 -0
- package/skills/nobrainer-team/references/team-plan.md +64 -0
- package/skills/nobrainer-tech-flow/SKILL.md +239 -0
- package/skills/nobrainer-tech-flow/VERSION +1 -0
- package/skills/nobrainer-tech-flow/references/autopilot.md +72 -0
- package/skills/nobrainer-tech-flow/references/codex-routing.md +21 -0
- package/skills/nobrainer-tech-flow/references/communication.md +108 -0
- package/skills/nobrainer-tech-flow/references/correction-hooks.md +110 -0
- package/skills/nobrainer-tech-flow/references/daily-update.md +28 -0
- package/skills/nobrainer-tech-flow/references/delivery.md +243 -0
- package/skills/nobrainer-tech-flow/references/idea-to-delivery.md +82 -0
- package/skills/nobrainer-tech-flow/references/long-run-state.md +209 -0
- package/skills/nobrainer-tech-flow/references/model-routing.md +83 -0
- package/skills/nobrainer-tech-flow/references/optional-decisions.md +83 -0
- package/skills/nobrainer-tech-flow/references/project-start.md +22 -0
- package/skills/nobrainer-tech-flow/references/routing.md +125 -0
- package/skills/nobrainer-tech-flow/references/setup.md +180 -0
- package/skills/nobrainer-tech-flow/references/worktrees.md +138 -0
- package/skills/nobrainer-tech-flow/scripts/check_flow_update.py +298 -0
- package/skills/nobrainer-tech-flow/scripts/cleanup_worktree.py +327 -0
- package/skills/nobrainer-tech-flow/scripts/decision_config.py +107 -0
- package/skills/nobrainer-tech-flow/scripts/flow_guard.py +252 -0
- package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +210 -0
- package/skills/nobrainer-wiki/SKILL.md +164 -0
- package/skills/nobrainer-wiki/references/setup.md +88 -0
- package/skills/nobrainer-writing/SKILL.md +272 -0
- package/skills/nobrainer-writing/agents/openai.yaml +4 -0
- package/skills/nobrainer-writing/references/brief-artifacts.md +357 -0
- package/skills/nobrainer-writing/references/plain-technical-english.md +76 -0
- 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())
|