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