@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/README.zh-CN.md +158 -0
  4. package/bin/r2p.js +38 -0
  5. package/docs/req-to-plan-design.md +277 -0
  6. package/package.json +47 -0
  7. package/requirements.txt +1 -0
  8. package/tools/r2p +10 -0
  9. package/tools/r2p-continue +10 -0
  10. package/tools/r2p-gap-open +10 -0
  11. package/tools/r2p-gap-resolve +10 -0
  12. package/tools/r2p-reopen +10 -0
  13. package/tools/r2p-start +10 -0
  14. package/tools/r2p-status +10 -0
  15. package/tools/r2p-switch +10 -0
  16. package/tools/r2p-tier-lock +10 -0
  17. package/tools/workflow_cli/__init__.py +0 -0
  18. package/tools/workflow_cli/__main__.py +5 -0
  19. package/tools/workflow_cli/agent_shortcuts.py +778 -0
  20. package/tools/workflow_cli/agent_templates/claude/SKILL.md +34 -0
  21. package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +16 -0
  22. package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-open.md +8 -0
  23. package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-resolve.md +8 -0
  24. package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +8 -0
  25. package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +10 -0
  26. package/tools/workflow_cli/agent_templates/claude/commands/r2p-status.md +8 -0
  27. package/tools/workflow_cli/agent_templates/claude/commands/r2p-switch.md +8 -0
  28. package/tools/workflow_cli/agent_templates/claude/commands/r2p-tier-lock.md +8 -0
  29. package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +12 -0
  30. package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-open/SKILL.md +12 -0
  31. package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-resolve/SKILL.md +12 -0
  32. package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +12 -0
  33. package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +14 -0
  34. package/tools/workflow_cli/agent_templates/codex/skills/r2p-status/SKILL.md +12 -0
  35. package/tools/workflow_cli/agent_templates/codex/skills/r2p-switch/SKILL.md +12 -0
  36. package/tools/workflow_cli/agent_templates/codex/skills/r2p-tier-lock/SKILL.md +12 -0
  37. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +4 -0
  38. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-open.toml +4 -0
  39. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-resolve.toml +4 -0
  40. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +4 -0
  41. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +4 -0
  42. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-status.toml +4 -0
  43. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-switch.toml +4 -0
  44. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-tier-lock.toml +4 -0
  45. package/tools/workflow_cli/artifact.py +228 -0
  46. package/tools/workflow_cli/cli.py +1779 -0
  47. package/tools/workflow_cli/gates.py +471 -0
  48. package/tools/workflow_cli/install.py +900 -0
  49. package/tools/workflow_cli/install_cli.py +158 -0
  50. package/tools/workflow_cli/link_expander.py +102 -0
  51. package/tools/workflow_cli/models.py +504 -0
  52. package/tools/workflow_cli/output.py +91 -0
  53. package/tools/workflow_cli/repo_baseline.py +137 -0
  54. package/tools/workflow_cli/state.py +621 -0
  55. package/tools/workflow_cli/tier.py +201 -0
  56. package/tools/workflow_cli/tier_keywords.yaml +45 -0
  57. 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)