@xenonbyte/req-2-plan 0.2.3
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/LICENSE +21 -0
- package/README.md +172 -0
- package/README.zh-CN.md +158 -0
- package/bin/r2p.js +38 -0
- package/docs/req-to-plan-design.md +277 -0
- package/package.json +47 -0
- package/requirements.txt +1 -0
- package/tools/r2p +10 -0
- package/tools/r2p-continue +10 -0
- package/tools/r2p-gap-open +10 -0
- package/tools/r2p-gap-resolve +10 -0
- package/tools/r2p-reopen +10 -0
- package/tools/r2p-start +10 -0
- package/tools/r2p-status +10 -0
- package/tools/r2p-switch +10 -0
- package/tools/r2p-tier-lock +10 -0
- package/tools/workflow_cli/__init__.py +0 -0
- package/tools/workflow_cli/__main__.py +5 -0
- package/tools/workflow_cli/agent_shortcuts.py +778 -0
- package/tools/workflow_cli/agent_templates/claude/SKILL.md +34 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +16 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-open.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-resolve.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +10 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-status.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-switch.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-tier-lock.md +8 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-open/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-resolve/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +14 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-status/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-switch/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-tier-lock/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-open.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-resolve.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-status.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-switch.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-tier-lock.toml +4 -0
- package/tools/workflow_cli/artifact.py +228 -0
- package/tools/workflow_cli/cli.py +1779 -0
- package/tools/workflow_cli/gates.py +471 -0
- package/tools/workflow_cli/install.py +900 -0
- package/tools/workflow_cli/install_cli.py +158 -0
- package/tools/workflow_cli/link_expander.py +102 -0
- package/tools/workflow_cli/models.py +504 -0
- package/tools/workflow_cli/output.py +91 -0
- package/tools/workflow_cli/repo_baseline.py +137 -0
- package/tools/workflow_cli/state.py +621 -0
- package/tools/workflow_cli/tier.py +201 -0
- package/tools/workflow_cli/tier_keywords.yaml +45 -0
- package/tools/workflow_cli/version.py +1 -0
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
"""
|
|
2
|
+
InstallService — multi-platform install/uninstall for the r2p skill.
|
|
3
|
+
|
|
4
|
+
Supports: claude, codex, gemini
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
import hashlib
|
|
11
|
+
import shutil
|
|
12
|
+
import shlex
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from tools.workflow_cli.version import R2P_VERSION
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Constants
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
SCHEMA_VERSION = 1
|
|
25
|
+
|
|
26
|
+
SUPPORTED_PLATFORMS = ("claude", "codex", "gemini")
|
|
27
|
+
|
|
28
|
+
DEFAULT_PLATFORM_HOMES = {
|
|
29
|
+
"claude": Path.home() / ".claude",
|
|
30
|
+
"codex": Path.home() / ".codex",
|
|
31
|
+
"gemini": Path.home() / ".gemini",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
KNOWN_OBSOLETE_SHARED_WRAPPERS = frozenset({"r2p-adapt"})
|
|
35
|
+
|
|
36
|
+
KNOWN_OBSOLETE_PLATFORM_TARGETS = {
|
|
37
|
+
"claude": (("commands", "r2p-adapt.md"),),
|
|
38
|
+
"codex": (("skills", "r2p-adapt", "SKILL.md"),),
|
|
39
|
+
"gemini": (("commands", "r2p-adapt.toml"),),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now_ts() -> str:
|
|
44
|
+
"""Return UTC timestamp for backup filenames."""
|
|
45
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _iso_now() -> str:
|
|
49
|
+
"""Return ISO 8601 datetime string for manifest."""
|
|
50
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# InstallService
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InstallService:
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
repo_root: Path,
|
|
62
|
+
manifest_root: Path,
|
|
63
|
+
platform_homes: dict[str, Path] | None = None,
|
|
64
|
+
):
|
|
65
|
+
self.repo_root = repo_root
|
|
66
|
+
self.manifest_root = manifest_root
|
|
67
|
+
self.platform_homes: dict[str, Path] = dict(
|
|
68
|
+
platform_homes if platform_homes is not None else DEFAULT_PLATFORM_HOMES
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Public API
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def install(self, platform: str) -> dict:
|
|
76
|
+
"""Install platform, overwriting any existing install. Returns manifest dict.
|
|
77
|
+
|
|
78
|
+
Raises ValueError on unknown platform. An existing install is removed first
|
|
79
|
+
(clean reinstall); per-file backups still guard pre-existing user files.
|
|
80
|
+
"""
|
|
81
|
+
if platform not in SUPPORTED_PLATFORMS:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Unknown platform: {platform!r}. Supported: {SUPPORTED_PLATFORMS}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
manifest_path = self._manifest_path(platform)
|
|
87
|
+
if manifest_path.exists():
|
|
88
|
+
self.uninstall(platform)
|
|
89
|
+
|
|
90
|
+
installed_paths: list[str] = []
|
|
91
|
+
backups: list[dict[str, str]] = []
|
|
92
|
+
written: list[Path] = []
|
|
93
|
+
backup_dir = self.manifest_root / "install" / "backups" / platform
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
bin_dir = self.manifest_root / "bin"
|
|
97
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
# Copy bin scripts
|
|
100
|
+
for src in sorted(self.repo_root.glob("tools/r2p-*")):
|
|
101
|
+
if src.is_file():
|
|
102
|
+
dest = bin_dir / src.name
|
|
103
|
+
content = _render_bin_script(
|
|
104
|
+
src.read_text(encoding="utf-8"),
|
|
105
|
+
self.repo_root,
|
|
106
|
+
)
|
|
107
|
+
_safe_write(
|
|
108
|
+
dest, content, backups, installed_paths, written, backup_dir
|
|
109
|
+
)
|
|
110
|
+
shutil.copymode(str(src), str(dest))
|
|
111
|
+
|
|
112
|
+
# Copy platform templates
|
|
113
|
+
template_dir = (
|
|
114
|
+
self.repo_root / "tools" / "workflow_cli" / "agent_templates" / platform
|
|
115
|
+
)
|
|
116
|
+
platform_home = self.platform_homes[platform]
|
|
117
|
+
|
|
118
|
+
if platform == "claude":
|
|
119
|
+
# SKILL.md → <claude_home>/skills/r2p/SKILL.md
|
|
120
|
+
skill_src = template_dir / "SKILL.md"
|
|
121
|
+
skill_dest = platform_home / "skills" / "r2p" / "SKILL.md"
|
|
122
|
+
content = _render(skill_src.read_text(), R2P_VERSION, str(bin_dir))
|
|
123
|
+
_safe_write(
|
|
124
|
+
skill_dest, content, backups, installed_paths, written, backup_dir
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# commands/r2p-*.md → <claude_home>/commands/r2p-*.md
|
|
128
|
+
cmd_dir = template_dir / "commands"
|
|
129
|
+
for src in sorted(cmd_dir.glob("r2p-*.md")):
|
|
130
|
+
dest = platform_home / "commands" / src.name
|
|
131
|
+
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
132
|
+
_safe_write(
|
|
133
|
+
dest, content, backups, installed_paths, written, backup_dir
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
elif platform == "codex":
|
|
137
|
+
# skills/r2p-*/SKILL.md → <codex_home>/skills/r2p-*/SKILL.md
|
|
138
|
+
skills_dir = template_dir / "skills"
|
|
139
|
+
for src in sorted(skills_dir.glob("r2p-*/SKILL.md")):
|
|
140
|
+
dest = platform_home / "skills" / src.parent.name / "SKILL.md"
|
|
141
|
+
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
142
|
+
_safe_write(
|
|
143
|
+
dest, content, backups, installed_paths, written, backup_dir
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
elif platform == "gemini":
|
|
147
|
+
# commands/r2p-*.toml → <gemini_home>/commands/r2p-*.toml
|
|
148
|
+
cmd_dir = template_dir / "commands"
|
|
149
|
+
for src in sorted(cmd_dir.glob("r2p-*.toml")):
|
|
150
|
+
dest = platform_home / "commands" / src.name
|
|
151
|
+
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
152
|
+
_safe_write(
|
|
153
|
+
dest, content, backups, installed_paths, written, backup_dir
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Write manifest
|
|
157
|
+
manifest: dict[str, Any] = {
|
|
158
|
+
"backups": backups,
|
|
159
|
+
"installed_at": _iso_now(),
|
|
160
|
+
"installed_paths": installed_paths,
|
|
161
|
+
"platform": platform,
|
|
162
|
+
"r2p_version": R2P_VERSION,
|
|
163
|
+
"schema_version": SCHEMA_VERSION,
|
|
164
|
+
}
|
|
165
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
manifest_path.write_text(_dump_manifest(manifest), encoding="utf-8")
|
|
167
|
+
|
|
168
|
+
# Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
|
|
169
|
+
# are no longer part of the current template set, across all manifests.
|
|
170
|
+
self._cleanup_obsolete_managed_wrappers()
|
|
171
|
+
return manifest
|
|
172
|
+
|
|
173
|
+
except Exception:
|
|
174
|
+
# Rollback: remove written files, restore backups
|
|
175
|
+
for path in reversed(written):
|
|
176
|
+
try:
|
|
177
|
+
path.unlink(missing_ok=True)
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
for bk in backups:
|
|
181
|
+
backup_path = Path(bk["backup"])
|
|
182
|
+
target_path = Path(bk["target"])
|
|
183
|
+
if backup_path.exists():
|
|
184
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
shutil.copy2(str(backup_path), str(target_path))
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
def uninstall(self, platform: str) -> dict:
|
|
189
|
+
"""Uninstall platform. Returns removed paths. Raises FileNotFoundError if no manifest."""
|
|
190
|
+
manifest_path = self._manifest_path(platform)
|
|
191
|
+
if not manifest_path.exists():
|
|
192
|
+
raise FileNotFoundError(
|
|
193
|
+
f"No manifest for platform {platform!r}. Not installed?"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
manifest = _load_manifest(manifest_path)
|
|
197
|
+
self._validate_manifest_for_uninstall(platform, manifest)
|
|
198
|
+
removed: list[str] = []
|
|
199
|
+
restored: list[str] = []
|
|
200
|
+
restored_targets: set[str] = set()
|
|
201
|
+
|
|
202
|
+
# Collect paths that have backups (these should be restored, not deleted)
|
|
203
|
+
backed_up_targets: set[str] = set()
|
|
204
|
+
for bk in manifest.get("backups", []):
|
|
205
|
+
backed_up_targets.add(str(bk["target"]))
|
|
206
|
+
|
|
207
|
+
other_platforms_installed = self._other_platforms_have_manifests(platform)
|
|
208
|
+
bin_dir = self.manifest_root / "bin"
|
|
209
|
+
|
|
210
|
+
# Remove installed paths (skip paths that will be restored from backup)
|
|
211
|
+
for path_str in manifest.get("installed_paths", []):
|
|
212
|
+
if path_str in backed_up_targets:
|
|
213
|
+
# Will be overwritten by backup restore below — skip deletion
|
|
214
|
+
continue
|
|
215
|
+
p = Path(path_str)
|
|
216
|
+
if other_platforms_installed and p.is_relative_to(bin_dir):
|
|
217
|
+
# r2p-* wrappers are shared by every platform manifest.
|
|
218
|
+
continue
|
|
219
|
+
if p.exists():
|
|
220
|
+
p.unlink()
|
|
221
|
+
removed.append(path_str)
|
|
222
|
+
|
|
223
|
+
# Restore user backups (reverse order). Managed wrapper backups are
|
|
224
|
+
# generated r2p files from another platform install, not user originals.
|
|
225
|
+
discarded_managed_backup_targets: set[str] = set()
|
|
226
|
+
for bk in reversed(manifest.get("backups", [])):
|
|
227
|
+
backup_path = Path(bk["backup"])
|
|
228
|
+
target_path = Path(bk["target"])
|
|
229
|
+
if backup_path.exists():
|
|
230
|
+
if not self._is_managed_wrapper_backup(str(target_path), backup_path):
|
|
231
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
shutil.copy2(str(backup_path), str(target_path))
|
|
233
|
+
restored.append(str(target_path))
|
|
234
|
+
restored_targets.add(str(target_path))
|
|
235
|
+
else:
|
|
236
|
+
discarded_managed_backup_targets.add(str(target_path))
|
|
237
|
+
backup_path.unlink(missing_ok=True)
|
|
238
|
+
|
|
239
|
+
if not other_platforms_installed:
|
|
240
|
+
for path_str in discarded_managed_backup_targets - restored_targets:
|
|
241
|
+
p = Path(path_str)
|
|
242
|
+
if p.is_relative_to(bin_dir) and p.exists():
|
|
243
|
+
p.unlink()
|
|
244
|
+
removed.append(path_str)
|
|
245
|
+
|
|
246
|
+
# Clean obsolete managed shared wrappers while this manifest can still
|
|
247
|
+
# prove ownership of stale shared bin paths.
|
|
248
|
+
self._cleanup_obsolete_managed_wrappers(preserve_paths=restored_targets)
|
|
249
|
+
|
|
250
|
+
# Reference-count bin dir: on final platform uninstall, remove the
|
|
251
|
+
# directory only when managed path cleanup left it empty. Files not
|
|
252
|
+
# recorded in the manifest are user-owned and must survive uninstall.
|
|
253
|
+
if not other_platforms_installed and bin_dir.exists():
|
|
254
|
+
try:
|
|
255
|
+
bin_dir.rmdir()
|
|
256
|
+
except OSError:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
# Clean up empty backup directory for this platform
|
|
260
|
+
backup_dir = self.manifest_root / "install" / "backups" / platform
|
|
261
|
+
if backup_dir.exists():
|
|
262
|
+
try:
|
|
263
|
+
backup_dir.rmdir() # only removes if empty
|
|
264
|
+
except OSError:
|
|
265
|
+
pass # non-empty is OK (unexpected files left by user)
|
|
266
|
+
|
|
267
|
+
# Remove the manifest itself
|
|
268
|
+
manifest_path.unlink(missing_ok=True)
|
|
269
|
+
|
|
270
|
+
return {"removed": removed, "restored": restored, "platform": platform}
|
|
271
|
+
|
|
272
|
+
def status(self) -> list[dict]:
|
|
273
|
+
"""Read-only install status per platform.
|
|
274
|
+
|
|
275
|
+
Each item: {platform, schema_version, r2p_version, installed_at,
|
|
276
|
+
status: 'ok' | 'drift' | 'invalid', issues: [str]}.
|
|
277
|
+
|
|
278
|
+
A manifest that is unreadable or has the wrong shape reports `invalid`
|
|
279
|
+
rather than crashing or being mistaken for a healthy install.
|
|
280
|
+
"""
|
|
281
|
+
result = []
|
|
282
|
+
install_dir = self.manifest_root / "install"
|
|
283
|
+
if not install_dir.exists():
|
|
284
|
+
return result
|
|
285
|
+
for platform in SUPPORTED_PLATFORMS:
|
|
286
|
+
mp = self._manifest_path(platform)
|
|
287
|
+
if not mp.exists():
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
data = _load_manifest(mp)
|
|
292
|
+
except Exception as exc: # truncated / unparseable manifest
|
|
293
|
+
result.append(
|
|
294
|
+
{
|
|
295
|
+
"platform": platform,
|
|
296
|
+
"schema_version": None,
|
|
297
|
+
"r2p_version": None,
|
|
298
|
+
"installed_at": None,
|
|
299
|
+
"status": "invalid",
|
|
300
|
+
"issues": [f"unreadable_manifest: {exc}"],
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
shape_issues = _manifest_shape_issues(data, platform)
|
|
306
|
+
if shape_issues:
|
|
307
|
+
meta = data if isinstance(data, dict) else {}
|
|
308
|
+
result.append(
|
|
309
|
+
{
|
|
310
|
+
"platform": meta.get("platform", platform),
|
|
311
|
+
"schema_version": meta.get("schema_version"),
|
|
312
|
+
"r2p_version": meta.get("r2p_version"),
|
|
313
|
+
"installed_at": meta.get("installed_at"),
|
|
314
|
+
"status": "invalid",
|
|
315
|
+
"issues": shape_issues,
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
issues: list[str] = []
|
|
321
|
+
for path_str in data.get("installed_paths", []):
|
|
322
|
+
if not Path(path_str).exists():
|
|
323
|
+
issues.append(f"missing_file: {path_str}")
|
|
324
|
+
if data.get("r2p_version") != R2P_VERSION:
|
|
325
|
+
issues.append(
|
|
326
|
+
f"version_mismatch: manifest={data.get('r2p_version')!r} "
|
|
327
|
+
f"current={R2P_VERSION!r}"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
result.append(
|
|
331
|
+
{
|
|
332
|
+
"platform": data.get("platform"),
|
|
333
|
+
"schema_version": data.get("schema_version"),
|
|
334
|
+
"r2p_version": data.get("r2p_version"),
|
|
335
|
+
"installed_at": data.get("installed_at"),
|
|
336
|
+
"status": "ok" if not issues else "drift",
|
|
337
|
+
"issues": issues,
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# Helpers
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def _manifest_path(self, platform: str) -> Path:
|
|
347
|
+
return self.manifest_root / "install" / f"{platform}.yaml"
|
|
348
|
+
|
|
349
|
+
def _other_platforms_have_manifests(self, excluding: str) -> bool:
|
|
350
|
+
"""Return True if any other platform has an installed manifest."""
|
|
351
|
+
for platform in SUPPORTED_PLATFORMS:
|
|
352
|
+
if platform == excluding:
|
|
353
|
+
continue
|
|
354
|
+
if self._manifest_path(platform).exists():
|
|
355
|
+
return True
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
def _validate_manifest_for_uninstall(self, platform: str, manifest: dict) -> None:
|
|
359
|
+
shape_issues = _manifest_shape_issues(manifest, platform)
|
|
360
|
+
if shape_issues:
|
|
361
|
+
raise ValueError(f"unsafe_manifest: {', '.join(shape_issues)}")
|
|
362
|
+
|
|
363
|
+
backups = manifest.get("backups", [])
|
|
364
|
+
if not isinstance(backups, list):
|
|
365
|
+
raise ValueError("unsafe_manifest: backups_not_a_list")
|
|
366
|
+
|
|
367
|
+
expected_targets = {
|
|
368
|
+
self._normalize_without_resolving_symlinks(path)
|
|
369
|
+
for path in self._expected_managed_targets(platform)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for path_str in manifest.get("installed_paths", []):
|
|
373
|
+
self._validate_manifest_target(
|
|
374
|
+
path_str,
|
|
375
|
+
expected_targets,
|
|
376
|
+
field="installed_paths",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
for index, backup in enumerate(backups):
|
|
380
|
+
if not isinstance(backup, dict):
|
|
381
|
+
raise ValueError(f"unsafe_manifest: backups[{index}]_not_a_mapping")
|
|
382
|
+
target = backup.get("target")
|
|
383
|
+
backup_path = backup.get("backup")
|
|
384
|
+
self._validate_manifest_target(
|
|
385
|
+
target,
|
|
386
|
+
expected_targets,
|
|
387
|
+
field=f"backups[{index}].target",
|
|
388
|
+
)
|
|
389
|
+
self._validate_backup_path(
|
|
390
|
+
platform,
|
|
391
|
+
backup_path,
|
|
392
|
+
field=f"backups[{index}].backup",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def _validate_manifest_target(
|
|
396
|
+
self,
|
|
397
|
+
raw_path: Any,
|
|
398
|
+
expected_targets: set[Path],
|
|
399
|
+
*,
|
|
400
|
+
field: str,
|
|
401
|
+
) -> Path:
|
|
402
|
+
if not isinstance(raw_path, str):
|
|
403
|
+
raise ValueError(f"unsafe_manifest: {field}_not_a_string")
|
|
404
|
+
|
|
405
|
+
path = Path(raw_path)
|
|
406
|
+
if not path.is_absolute():
|
|
407
|
+
raise ValueError(f"unsafe_manifest: {field}_not_absolute")
|
|
408
|
+
if ".." in path.parts:
|
|
409
|
+
raise ValueError(f"unsafe_manifest: {field}_parent_ref")
|
|
410
|
+
if path.is_symlink():
|
|
411
|
+
raise ValueError(f"unsafe_manifest: {field}_is_symlink")
|
|
412
|
+
|
|
413
|
+
normalized = self._normalize_without_resolving_symlinks(path)
|
|
414
|
+
if normalized not in expected_targets:
|
|
415
|
+
raise ValueError(f"unsafe_manifest: {field}_outside_managed_targets")
|
|
416
|
+
self._reject_symlinked_ancestors(normalized, field=field)
|
|
417
|
+
return path
|
|
418
|
+
|
|
419
|
+
def _validate_backup_path(
|
|
420
|
+
self,
|
|
421
|
+
platform: str,
|
|
422
|
+
raw_path: Any,
|
|
423
|
+
*,
|
|
424
|
+
field: str,
|
|
425
|
+
) -> Path:
|
|
426
|
+
if not isinstance(raw_path, str):
|
|
427
|
+
raise ValueError(f"unsafe_manifest: {field}_not_a_string")
|
|
428
|
+
|
|
429
|
+
path = Path(raw_path)
|
|
430
|
+
if not path.is_absolute():
|
|
431
|
+
raise ValueError(f"unsafe_manifest: {field}_not_absolute")
|
|
432
|
+
if ".." in path.parts:
|
|
433
|
+
raise ValueError(f"unsafe_manifest: {field}_parent_ref")
|
|
434
|
+
if path.is_symlink():
|
|
435
|
+
raise ValueError(f"unsafe_manifest: {field}_is_symlink")
|
|
436
|
+
|
|
437
|
+
backup_dir = self._normalize_without_resolving_symlinks(
|
|
438
|
+
self.manifest_root / "install" / "backups" / platform
|
|
439
|
+
)
|
|
440
|
+
normalized = self._normalize_without_resolving_symlinks(path)
|
|
441
|
+
if normalized == backup_dir or not normalized.is_relative_to(backup_dir):
|
|
442
|
+
raise ValueError(f"unsafe_manifest: {field}_outside_backup_dir")
|
|
443
|
+
self._reject_symlinked_ancestors(normalized, field=field)
|
|
444
|
+
return path
|
|
445
|
+
|
|
446
|
+
def _normalize_without_resolving_symlinks(self, path: Path) -> Path:
|
|
447
|
+
return Path(os.path.abspath(path))
|
|
448
|
+
|
|
449
|
+
def _managed_scan_boundary(self, normalized_path: Path) -> Path:
|
|
450
|
+
"""Return the trusted ancestor *below* which a symlink is rejected.
|
|
451
|
+
|
|
452
|
+
Platform homes (``~/.claude`` …) and the manifest root are operator-
|
|
453
|
+
configured trusted roots. A user may legitimately symlink a platform
|
|
454
|
+
home (stow/chezmoi), so symlinks are only rejected *inside* it — the
|
|
455
|
+
home itself is trusted. The manifest root keeps the stricter boundary
|
|
456
|
+
(its own parent) so a symlinked manifest root is still rejected,
|
|
457
|
+
matching the install/backup ownership model.
|
|
458
|
+
"""
|
|
459
|
+
best_home: Path | None = None
|
|
460
|
+
for home in self.platform_homes.values():
|
|
461
|
+
home_norm = self._normalize_without_resolving_symlinks(home)
|
|
462
|
+
if normalized_path == home_norm or normalized_path.is_relative_to(home_norm):
|
|
463
|
+
if best_home is None or len(home_norm.parts) > len(best_home.parts):
|
|
464
|
+
best_home = home_norm
|
|
465
|
+
if best_home is not None:
|
|
466
|
+
return best_home
|
|
467
|
+
return self._normalize_without_resolving_symlinks(self.manifest_root).parent
|
|
468
|
+
|
|
469
|
+
def _reject_symlinked_ancestors(self, normalized_path: Path, *, field: str) -> None:
|
|
470
|
+
"""Reject any symlink among the path's ancestors below its trusted root.
|
|
471
|
+
|
|
472
|
+
Only ancestors strictly under the trusted boundary are checked, so a
|
|
473
|
+
user-symlinked platform home is tolerated while an attacker-injected
|
|
474
|
+
symlink swapped in for a managed intermediate directory is not.
|
|
475
|
+
"""
|
|
476
|
+
boundary = self._managed_scan_boundary(normalized_path)
|
|
477
|
+
try:
|
|
478
|
+
rel = normalized_path.parent.relative_to(boundary)
|
|
479
|
+
except ValueError:
|
|
480
|
+
raise ValueError(f"unsafe_manifest: {field}_outside_managed_root")
|
|
481
|
+
current = boundary
|
|
482
|
+
for part in rel.parts:
|
|
483
|
+
current = current / part
|
|
484
|
+
if current.is_symlink():
|
|
485
|
+
raise ValueError(f"unsafe_manifest: {field}_symlinked_ancestor")
|
|
486
|
+
|
|
487
|
+
def _expected_managed_targets(self, platform: str) -> set[Path]:
|
|
488
|
+
if platform not in SUPPORTED_PLATFORMS:
|
|
489
|
+
raise ValueError(f"unsafe_manifest: unsupported_platform {platform!r}")
|
|
490
|
+
|
|
491
|
+
bin_dir = self.manifest_root / "bin"
|
|
492
|
+
targets = {
|
|
493
|
+
bin_dir / src.name
|
|
494
|
+
for src in sorted(self.repo_root.glob("tools/r2p-*"))
|
|
495
|
+
if src.is_file()
|
|
496
|
+
}
|
|
497
|
+
targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
|
|
498
|
+
|
|
499
|
+
template_dir = (
|
|
500
|
+
self.repo_root / "tools" / "workflow_cli" / "agent_templates" / platform
|
|
501
|
+
)
|
|
502
|
+
platform_home = self.platform_homes[platform]
|
|
503
|
+
for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
|
|
504
|
+
targets.add(platform_home.joinpath(*parts))
|
|
505
|
+
|
|
506
|
+
if platform == "claude":
|
|
507
|
+
targets.add(platform_home / "skills" / "r2p" / "SKILL.md")
|
|
508
|
+
for src in sorted((template_dir / "commands").glob("r2p-*.md")):
|
|
509
|
+
targets.add(platform_home / "commands" / src.name)
|
|
510
|
+
elif platform == "codex":
|
|
511
|
+
for src in sorted((template_dir / "skills").glob("r2p-*/SKILL.md")):
|
|
512
|
+
targets.add(platform_home / "skills" / src.parent.name / "SKILL.md")
|
|
513
|
+
elif platform == "gemini":
|
|
514
|
+
for src in sorted((template_dir / "commands").glob("r2p-*.toml")):
|
|
515
|
+
targets.add(platform_home / "commands" / src.name)
|
|
516
|
+
|
|
517
|
+
return targets
|
|
518
|
+
|
|
519
|
+
def _cleanup_obsolete_managed_wrappers(
|
|
520
|
+
self, preserve_paths: set[str] | None = None
|
|
521
|
+
) -> None:
|
|
522
|
+
"""Remove managed shared bin/r2p-* wrappers that are no longer part of the
|
|
523
|
+
current template set, across every installed platform manifest.
|
|
524
|
+
|
|
525
|
+
Obsolete candidates are discovered only from valid manifest references
|
|
526
|
+
to known obsolete shared wrappers. A manifest entry for an arbitrary
|
|
527
|
+
``bin/r2p-*`` path is not proof that this installer owns that file.
|
|
528
|
+
"""
|
|
529
|
+
preserve_paths = preserve_paths or set()
|
|
530
|
+
current_wrappers = {
|
|
531
|
+
p.name for p in sorted(self.repo_root.glob("tools/r2p-*")) if p.is_file()
|
|
532
|
+
}
|
|
533
|
+
bin_dir = self.manifest_root / "bin"
|
|
534
|
+
install_dir = self.manifest_root / "install"
|
|
535
|
+
if not install_dir.exists():
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
# Discover obsolete managed wrapper paths from manifest references only.
|
|
539
|
+
obsolete: set[str] = set()
|
|
540
|
+
for mpath in sorted(install_dir.glob("*.yaml")):
|
|
541
|
+
manifest = self._load_manifest_for_cleanup(mpath)
|
|
542
|
+
if manifest is None:
|
|
543
|
+
continue
|
|
544
|
+
platform = mpath.stem
|
|
545
|
+
if platform not in SUPPORTED_PLATFORMS:
|
|
546
|
+
continue
|
|
547
|
+
if _manifest_shape_issues(manifest, platform):
|
|
548
|
+
continue
|
|
549
|
+
expected_targets = {
|
|
550
|
+
self._normalize_without_resolving_symlinks(path)
|
|
551
|
+
for path in self._expected_managed_targets(platform)
|
|
552
|
+
}
|
|
553
|
+
installed_refs = manifest.get("installed_paths", [])
|
|
554
|
+
refs = list(installed_refs) if isinstance(installed_refs, list) else []
|
|
555
|
+
backups = manifest.get("backups", [])
|
|
556
|
+
if isinstance(backups, list):
|
|
557
|
+
refs += [
|
|
558
|
+
bk.get("target")
|
|
559
|
+
for bk in backups
|
|
560
|
+
if isinstance(bk, dict)
|
|
561
|
+
]
|
|
562
|
+
for ref in refs:
|
|
563
|
+
if not isinstance(ref, str):
|
|
564
|
+
continue
|
|
565
|
+
p = Path(ref)
|
|
566
|
+
if (
|
|
567
|
+
p.parent == bin_dir
|
|
568
|
+
and p.name in KNOWN_OBSOLETE_SHARED_WRAPPERS
|
|
569
|
+
and p.name not in current_wrappers
|
|
570
|
+
):
|
|
571
|
+
try:
|
|
572
|
+
self._validate_manifest_target(
|
|
573
|
+
ref,
|
|
574
|
+
expected_targets,
|
|
575
|
+
field="obsolete_wrapper",
|
|
576
|
+
)
|
|
577
|
+
except ValueError:
|
|
578
|
+
continue
|
|
579
|
+
obsolete.add(str(p))
|
|
580
|
+
|
|
581
|
+
if not obsolete:
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
for path_str in obsolete:
|
|
585
|
+
# If an older manifest backed up the user's pre-existing file at this
|
|
586
|
+
# path, restore it before dropping the metadata — otherwise the user's
|
|
587
|
+
# only copy is orphaned and can never be restored by uninstall.
|
|
588
|
+
restored = self._restore_managed_wrapper_backup(path_str)
|
|
589
|
+
for mpath in sorted(install_dir.glob("*.yaml")):
|
|
590
|
+
if self._load_manifest_for_cleanup(mpath) is None:
|
|
591
|
+
continue
|
|
592
|
+
self._strip_path_from_manifest(mpath, path_str)
|
|
593
|
+
# Delete the obsolete managed wrapper only when there was no user
|
|
594
|
+
# original to restore in its place.
|
|
595
|
+
if not restored and path_str not in preserve_paths:
|
|
596
|
+
Path(path_str).unlink(missing_ok=True)
|
|
597
|
+
|
|
598
|
+
def _restore_managed_wrapper_backup(self, path_str: str) -> bool:
|
|
599
|
+
"""Restore a user's pre-existing file from any manifest backup whose target
|
|
600
|
+
is ``path_str``, consuming the backup. Returns True if a backup was restored.
|
|
601
|
+
|
|
602
|
+
Mirrors the uninstall restore step so cleaning up an obsolete managed
|
|
603
|
+
wrapper never destroys the user's original file. Backups that contain an
|
|
604
|
+
r2p-managed wrapper are discarded instead of restored; those are backups
|
|
605
|
+
of a shared wrapper already written by another platform install."""
|
|
606
|
+
target = Path(path_str)
|
|
607
|
+
user_backups: list[tuple[str, str, int, Path]] = []
|
|
608
|
+
managed_backups: list[Path] = []
|
|
609
|
+
|
|
610
|
+
for mpath in sorted((self.manifest_root / "install").glob("*.yaml")):
|
|
611
|
+
manifest = self._load_manifest_for_cleanup(mpath)
|
|
612
|
+
if manifest is None:
|
|
613
|
+
continue
|
|
614
|
+
platform = mpath.stem
|
|
615
|
+
if platform not in SUPPORTED_PLATFORMS:
|
|
616
|
+
continue
|
|
617
|
+
expected_targets = {
|
|
618
|
+
self._normalize_without_resolving_symlinks(path)
|
|
619
|
+
for path in self._expected_managed_targets(platform)
|
|
620
|
+
}
|
|
621
|
+
installed_at = str(manifest.get("installed_at", ""))
|
|
622
|
+
for index, bk in enumerate(manifest.get("backups", [])):
|
|
623
|
+
if not isinstance(bk, dict):
|
|
624
|
+
continue
|
|
625
|
+
if str(bk.get("target")) != path_str:
|
|
626
|
+
continue
|
|
627
|
+
backup = bk.get("backup")
|
|
628
|
+
if not backup:
|
|
629
|
+
continue
|
|
630
|
+
try:
|
|
631
|
+
self._validate_manifest_target(
|
|
632
|
+
bk.get("target"),
|
|
633
|
+
expected_targets,
|
|
634
|
+
field="backups.target",
|
|
635
|
+
)
|
|
636
|
+
self._validate_backup_path(
|
|
637
|
+
platform,
|
|
638
|
+
backup,
|
|
639
|
+
field="backups.backup",
|
|
640
|
+
)
|
|
641
|
+
except ValueError:
|
|
642
|
+
continue
|
|
643
|
+
backup_path = Path(backup)
|
|
644
|
+
if not backup_path.is_file():
|
|
645
|
+
continue
|
|
646
|
+
if self._is_managed_wrapper_backup(path_str, backup_path):
|
|
647
|
+
managed_backups.append(backup_path)
|
|
648
|
+
else:
|
|
649
|
+
user_backups.append((installed_at, mpath.name, index, backup_path))
|
|
650
|
+
|
|
651
|
+
restored = False
|
|
652
|
+
if user_backups:
|
|
653
|
+
_, _, _, backup_path = sorted(user_backups, key=lambda item: item[:3])[0]
|
|
654
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
655
|
+
shutil.copy2(str(backup_path), str(target))
|
|
656
|
+
backup_path.unlink(missing_ok=True)
|
|
657
|
+
restored = True
|
|
658
|
+
|
|
659
|
+
for backup_path in managed_backups:
|
|
660
|
+
backup_path.unlink(missing_ok=True)
|
|
661
|
+
|
|
662
|
+
return restored
|
|
663
|
+
|
|
664
|
+
def _is_managed_wrapper_backup(self, path_str: str, backup_path: Path) -> bool:
|
|
665
|
+
target = Path(path_str)
|
|
666
|
+
bin_dir = self.manifest_root / "bin"
|
|
667
|
+
if target.parent != bin_dir or not target.name.startswith("r2p-"):
|
|
668
|
+
return False
|
|
669
|
+
try:
|
|
670
|
+
content = backup_path.read_text(encoding="utf-8")
|
|
671
|
+
except UnicodeDecodeError:
|
|
672
|
+
return False
|
|
673
|
+
return _looks_like_managed_bin_script(content)
|
|
674
|
+
|
|
675
|
+
def _strip_path_from_manifest(self, manifest_path: Path, path_str: str) -> None:
|
|
676
|
+
"""Remove ``path_str`` from a manifest's installed_paths and any matching
|
|
677
|
+
backups entry, rewriting the file only when it changed."""
|
|
678
|
+
manifest = _load_manifest(manifest_path)
|
|
679
|
+
changed = False
|
|
680
|
+
|
|
681
|
+
paths = manifest.get("installed_paths", [])
|
|
682
|
+
if path_str in paths:
|
|
683
|
+
manifest["installed_paths"] = [p for p in paths if p != path_str]
|
|
684
|
+
changed = True
|
|
685
|
+
|
|
686
|
+
backups = manifest.get("backups", [])
|
|
687
|
+
kept = [bk for bk in backups if str(bk.get("target")) != path_str]
|
|
688
|
+
if len(kept) != len(backups):
|
|
689
|
+
manifest["backups"] = kept
|
|
690
|
+
changed = True
|
|
691
|
+
|
|
692
|
+
if changed:
|
|
693
|
+
manifest_path.write_text(_dump_manifest(manifest), encoding="utf-8")
|
|
694
|
+
|
|
695
|
+
def _load_manifest_for_cleanup(self, manifest_path: Path) -> dict[str, Any] | None:
|
|
696
|
+
"""Load a manifest during best-effort shared-wrapper cleanup.
|
|
697
|
+
|
|
698
|
+
A malformed unrelated manifest should not leave the current install or
|
|
699
|
+
uninstall half-cleaned; the bad file remains in place for operator repair.
|
|
700
|
+
"""
|
|
701
|
+
try:
|
|
702
|
+
return _load_manifest(manifest_path)
|
|
703
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ---------------------------------------------------------------------------
|
|
708
|
+
# Internal helpers
|
|
709
|
+
# ---------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _render(content: str, version: str, bin_dir: str) -> str:
|
|
713
|
+
"""Substitute template placeholders."""
|
|
714
|
+
content = content.replace("{{R2P_VERSION}}", version)
|
|
715
|
+
content = content.replace("{{R2P_BIN_DIR}}", bin_dir)
|
|
716
|
+
return content
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _render_bin_script(content: str, repo_root: Path) -> str:
|
|
720
|
+
"""Render an installed wrapper so it imports modules from the source repo."""
|
|
721
|
+
return content.replace(
|
|
722
|
+
'REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"',
|
|
723
|
+
f"REPO_ROOT={shlex.quote(str(repo_root))}",
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _looks_like_managed_bin_script(content: str) -> bool:
|
|
728
|
+
return (
|
|
729
|
+
content.startswith("#!/usr/bin/env bash")
|
|
730
|
+
and 'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"' in content
|
|
731
|
+
and 'export PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}"' in content
|
|
732
|
+
and "-m tools.workflow_cli.agent_shortcuts" in content
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _dump_manifest(manifest: dict[str, Any]) -> str:
|
|
737
|
+
"""Return a manifest string readable as YAML without requiring PyYAML."""
|
|
738
|
+
return json.dumps(manifest, indent=2, sort_keys=True) + "\n"
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _load_manifest(path: Path) -> dict[str, Any]:
|
|
742
|
+
"""Load current JSON-formatted manifests and legacy simple YAML manifests."""
|
|
743
|
+
text = path.read_text(encoding="utf-8")
|
|
744
|
+
try:
|
|
745
|
+
data = json.loads(text)
|
|
746
|
+
except json.JSONDecodeError:
|
|
747
|
+
data = _load_legacy_manifest_yaml(text)
|
|
748
|
+
if not isinstance(data, dict):
|
|
749
|
+
raise ValueError(f"Invalid manifest at {path}: expected object")
|
|
750
|
+
return data
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _manifest_shape_issues(data: Any, platform: str) -> list[str]:
|
|
754
|
+
"""Return shape problems that make a parseable manifest still invalid.
|
|
755
|
+
|
|
756
|
+
A truncated or partial write can parse yet be missing required fields or
|
|
757
|
+
name the wrong platform; such a manifest must report `invalid`, not `ok`.
|
|
758
|
+
"""
|
|
759
|
+
if not isinstance(data, dict):
|
|
760
|
+
return ["manifest_not_a_mapping"]
|
|
761
|
+
issues: list[str] = []
|
|
762
|
+
if data.get("schema_version") is None:
|
|
763
|
+
issues.append("missing_schema_version")
|
|
764
|
+
if data.get("platform") != platform:
|
|
765
|
+
issues.append(
|
|
766
|
+
f"platform_mismatch: manifest={data.get('platform')!r} expected={platform!r}"
|
|
767
|
+
)
|
|
768
|
+
if not isinstance(data.get("installed_paths"), list):
|
|
769
|
+
issues.append("installed_paths_not_a_list")
|
|
770
|
+
if data.get("r2p_version") is None:
|
|
771
|
+
issues.append("missing_r2p_version")
|
|
772
|
+
return issues
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _load_legacy_manifest_yaml(text: str) -> dict[str, Any]:
|
|
776
|
+
"""Parse the limited manifest YAML shape written by older r2p versions."""
|
|
777
|
+
result: dict[str, Any] = {}
|
|
778
|
+
lines = text.splitlines()
|
|
779
|
+
i = 0
|
|
780
|
+
|
|
781
|
+
while i < len(lines):
|
|
782
|
+
line = lines[i]
|
|
783
|
+
if not line.strip() or line.startswith(" "):
|
|
784
|
+
i += 1
|
|
785
|
+
continue
|
|
786
|
+
if ":" not in line:
|
|
787
|
+
i += 1
|
|
788
|
+
continue
|
|
789
|
+
|
|
790
|
+
key, raw_value = line.split(":", 1)
|
|
791
|
+
value = raw_value.strip()
|
|
792
|
+
if value:
|
|
793
|
+
result[key] = _parse_manifest_scalar(value)
|
|
794
|
+
i += 1
|
|
795
|
+
continue
|
|
796
|
+
|
|
797
|
+
i += 1
|
|
798
|
+
items: list[Any] = []
|
|
799
|
+
while i < len(lines):
|
|
800
|
+
child = lines[i]
|
|
801
|
+
if not child.strip():
|
|
802
|
+
i += 1
|
|
803
|
+
continue
|
|
804
|
+
if not child.startswith("- ") and not child.startswith(" "):
|
|
805
|
+
break
|
|
806
|
+
|
|
807
|
+
if child.startswith("- "):
|
|
808
|
+
rest = child[2:].strip()
|
|
809
|
+
if ":" in rest:
|
|
810
|
+
item: dict[str, Any] = {}
|
|
811
|
+
child_key, child_value = rest.split(":", 1)
|
|
812
|
+
item[child_key.strip()] = _parse_manifest_scalar(child_value.strip())
|
|
813
|
+
i += 1
|
|
814
|
+
while i < len(lines) and lines[i].startswith(" "):
|
|
815
|
+
nested = lines[i].strip()
|
|
816
|
+
if nested and ":" in nested:
|
|
817
|
+
nested_key, nested_value = nested.split(":", 1)
|
|
818
|
+
item[nested_key.strip()] = _parse_manifest_scalar(
|
|
819
|
+
nested_value.strip()
|
|
820
|
+
)
|
|
821
|
+
i += 1
|
|
822
|
+
items.append(item)
|
|
823
|
+
else:
|
|
824
|
+
items.append(_parse_manifest_scalar(rest))
|
|
825
|
+
i += 1
|
|
826
|
+
else:
|
|
827
|
+
i += 1
|
|
828
|
+
|
|
829
|
+
result[key] = items
|
|
830
|
+
|
|
831
|
+
return result
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _parse_manifest_scalar(value: str) -> Any:
|
|
835
|
+
value = value.strip()
|
|
836
|
+
if value == "[]":
|
|
837
|
+
return []
|
|
838
|
+
if value == "{}":
|
|
839
|
+
return {}
|
|
840
|
+
if value in {"''", '""'}:
|
|
841
|
+
return ""
|
|
842
|
+
if (
|
|
843
|
+
len(value) >= 2
|
|
844
|
+
and ((value[0] == "'" and value[-1] == "'") or (value[0] == '"' and value[-1] == '"'))
|
|
845
|
+
):
|
|
846
|
+
value = value[1:-1]
|
|
847
|
+
if value.isdigit():
|
|
848
|
+
return int(value)
|
|
849
|
+
return value
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _backup_path(backup_dir: Path, dest: Path) -> Path:
|
|
853
|
+
path_hash = hashlib.sha256(str(dest).encode("utf-8")).hexdigest()[:12]
|
|
854
|
+
base = backup_dir / f"{dest.name}.{path_hash}.{_now_ts()}"
|
|
855
|
+
candidate = base
|
|
856
|
+
suffix = 1
|
|
857
|
+
while candidate.exists():
|
|
858
|
+
candidate = backup_dir / f"{base.name}.{suffix}"
|
|
859
|
+
suffix += 1
|
|
860
|
+
return candidate
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _safe_copy(
|
|
864
|
+
src: Path,
|
|
865
|
+
dest: Path,
|
|
866
|
+
backups: list[dict[str, str]],
|
|
867
|
+
installed_paths: list[str],
|
|
868
|
+
written: list[Path],
|
|
869
|
+
backup_dir: Path,
|
|
870
|
+
) -> None:
|
|
871
|
+
"""Copy src to dest, backing up dest to backup_dir if it already exists."""
|
|
872
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
873
|
+
if dest.exists():
|
|
874
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
875
|
+
backup = _backup_path(backup_dir, dest)
|
|
876
|
+
shutil.copy2(str(dest), str(backup))
|
|
877
|
+
backups.append({"target": str(dest), "backup": str(backup)})
|
|
878
|
+
shutil.copy2(str(src), str(dest))
|
|
879
|
+
installed_paths.append(str(dest))
|
|
880
|
+
written.append(dest)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _safe_write(
|
|
884
|
+
dest: Path,
|
|
885
|
+
content: str,
|
|
886
|
+
backups: list[dict[str, str]],
|
|
887
|
+
installed_paths: list[str],
|
|
888
|
+
written: list[Path],
|
|
889
|
+
backup_dir: Path,
|
|
890
|
+
) -> None:
|
|
891
|
+
"""Write content to dest, backing up dest to backup_dir if it already exists."""
|
|
892
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
893
|
+
if dest.exists():
|
|
894
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
895
|
+
backup = _backup_path(backup_dir, dest)
|
|
896
|
+
shutil.copy2(str(dest), str(backup))
|
|
897
|
+
backups.append({"target": str(dest), "backup": str(backup)})
|
|
898
|
+
dest.write_text(content, encoding="utf-8")
|
|
899
|
+
installed_paths.append(str(dest))
|
|
900
|
+
written.append(dest)
|