syncade 0.6.2__py3-none-any.whl
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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/cli/gc_mode.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""``syncade --gc`` mode handler.
|
|
2
|
+
|
|
3
|
+
A one-shot maintenance pass that prunes bulk transcripts from ``.syncade/runs/``
|
|
4
|
+
and removes ``/tmp/syncade/`` worktree leftovers, then safely reaps orphaned
|
|
5
|
+
subprocesses (cwd-scoped). Run history is never deleted. Split into its own
|
|
6
|
+
module so :mod:`syncade.cli.modes` stays under the blocking file-length cap;
|
|
7
|
+
re-exported from ``modes`` so the public import path is unchanged.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
|
|
16
|
+
from syncade.snapshot import SnapshotError, discover_repo_root
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run_gc(args) -> int:
|
|
20
|
+
"""Dispatch ``syncade --gc``: a one-shot maintenance pass that
|
|
21
|
+
prunes bulk transcripts from ``.syncade/runs/`` and removes
|
|
22
|
+
``/tmp/syncade/`` worktree leftovers, then safely reaps orphaned
|
|
23
|
+
subprocesses (cwd-scoped). Run history is never deleted.
|
|
24
|
+
|
|
25
|
+
Thin wrapper around :func:`syncade.gc.plan_gc` → :func:`syncade.gc.execute_gc`:
|
|
26
|
+
resolve the git repo root, build a plan, execute it (honoring
|
|
27
|
+
``--gc-dry-run``), and print a human-readable report. ``--gc`` operates
|
|
28
|
+
only on its own artifacts and is best-effort over them — per-item failures
|
|
29
|
+
are reported but never fail the pass.
|
|
30
|
+
|
|
31
|
+
Exit codes follow the CLI mode-handler convention (CLAUDE.md "Exit-code
|
|
32
|
+
convention for CLI mode handlers"): success → 0; repo-discovery failure →
|
|
33
|
+
60 (WORKTREE_ERROR). GC never raises out and partial per-item failures
|
|
34
|
+
still exit 0.
|
|
35
|
+
"""
|
|
36
|
+
# Local import keeps the gc module out of the default-import cost —
|
|
37
|
+
# operators not running --gc don't pay for it.
|
|
38
|
+
from syncade.cli.config_overrides import apply_worktree_base_override
|
|
39
|
+
from syncade.config_loader import ConfigError, load_config
|
|
40
|
+
from syncade.exit_codes import CONFIG_ERROR
|
|
41
|
+
from syncade.gc import execute_gc, plan_gc
|
|
42
|
+
|
|
43
|
+
repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
|
|
44
|
+
try:
|
|
45
|
+
repo_root = discover_repo_root(repo_root_hint)
|
|
46
|
+
except SnapshotError as exc:
|
|
47
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
48
|
+
return WORKTREE_ERROR
|
|
49
|
+
|
|
50
|
+
# A malformed [gc] value is a config ERROR, not a per-item GC failure: it fails exit 50 like
|
|
51
|
+
# every other config-consuming path (review, doctor), so a typo cannot silently run GC with the
|
|
52
|
+
# wrong retention. (--gc's never-raise contract is about per-item cleanup failures, not
|
|
53
|
+
# config validation.)
|
|
54
|
+
try:
|
|
55
|
+
# check_api_keys=False: --gc spawns no model actors, so a missing actor API key must not
|
|
56
|
+
# block maintenance — but a malformed [gc] value still fails schema validation → exit 50.
|
|
57
|
+
config = load_config(repo_root, preset=args.preset, check_api_keys=False)
|
|
58
|
+
except ConfigError as exc:
|
|
59
|
+
print(f"[syncade] config error: {exc}", file=sys.stderr)
|
|
60
|
+
return CONFIG_ERROR
|
|
61
|
+
config = apply_worktree_base_override(config, args) # --worktree-base cleans a custom base too
|
|
62
|
+
|
|
63
|
+
# Retention precedence: CLI --gc-keep/--gc-max-age-days → [gc] config → default (GcConfig's
|
|
64
|
+
# defaults reproduce gc.DEFAULT_KEEP / DEFAULT_MAX_AGE_DAYS). Flag defaults are None so "passed
|
|
65
|
+
# without a value" stays detectable in main()'s mutex.
|
|
66
|
+
keep = args.gc_keep if args.gc_keep is not None else config.gc.keep
|
|
67
|
+
max_age_days = (
|
|
68
|
+
args.gc_max_age_days if args.gc_max_age_days is not None else config.gc.max_age_days
|
|
69
|
+
)
|
|
70
|
+
# worktree_base: clean the base the runs actually use (config.worktree_base / --worktree-base),
|
|
71
|
+
# not the hardcoded default — else a relocated base leaves worktree leftovers uncollected.
|
|
72
|
+
plan = plan_gc(
|
|
73
|
+
repo_root, keep=keep, max_age_days=max_age_days, worktree_base=config.worktree_base
|
|
74
|
+
)
|
|
75
|
+
report = execute_gc(plan, dry_run=args.gc_dry_run, repo_root=repo_root)
|
|
76
|
+
|
|
77
|
+
dry = report.dry_run
|
|
78
|
+
prefix = "(dry run — nothing removed) " if dry else ""
|
|
79
|
+
print(
|
|
80
|
+
f"[syncade] {prefix}gc: {len(report.runs_slimmed)} run(s) "
|
|
81
|
+
f"{'would be ' if dry else ''}slimmed "
|
|
82
|
+
f"({_human_bytes(report.bytes_freed)} of transcripts "
|
|
83
|
+
f"{'would be ' if dry else ''}freed; run history kept), "
|
|
84
|
+
f"{len(plan.protected_run_ids)} protected, "
|
|
85
|
+
f"{len(report.worktrees_removed)} worktree(s) "
|
|
86
|
+
f"{'would be ' if dry else ''}removed, "
|
|
87
|
+
f"{len(report.pids_reaped)} process(es) {'would be reaped' if dry else 'reaped'}."
|
|
88
|
+
)
|
|
89
|
+
if not args.quiet:
|
|
90
|
+
for run_id in report.runs_slimmed:
|
|
91
|
+
print(f" {'would slim' if dry else 'slimmed'} run: {run_id}")
|
|
92
|
+
for tree in report.worktrees_removed:
|
|
93
|
+
print(f" {'would remove' if dry else 'removed'} worktree: {tree}")
|
|
94
|
+
for pid in report.pids_reaped:
|
|
95
|
+
print(f" {'would reap' if dry else 'reaped'} pid: {pid}")
|
|
96
|
+
for err in report.errors:
|
|
97
|
+
print(f"[syncade] gc: {err}", file=sys.stderr)
|
|
98
|
+
|
|
99
|
+
return SUCCESS
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _human_bytes(n: int) -> str:
|
|
103
|
+
if n < 1024:
|
|
104
|
+
return f"{n} B"
|
|
105
|
+
for unit in ("KB", "MB", "GB"):
|
|
106
|
+
n /= 1024.0 # type: ignore[assignment]
|
|
107
|
+
if n < 1024 or unit == "GB":
|
|
108
|
+
return f"{n:.1f} {unit}"
|
|
109
|
+
return f"{n:.1f} GB"
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""``syncade --install-skill`` — lay the bundled Agent Skill into the harness dirs.
|
|
2
|
+
|
|
3
|
+
The skill is shipped as package data (``src/syncade/skills/{claude,codex}/``), so a
|
|
4
|
+
``pip install``ed user with no repo checkout can still install the ``/syncade`` skill.
|
|
5
|
+
Read via :func:`importlib.resources.files` so it works from a wheel or an editable install
|
|
6
|
+
alike. This is the ONLY installer: a second, shell copy existed and was deleted in
|
|
7
|
+
PR-h-04.5 item 1. Two implementations of the same job is what made a PR-h-04 dogfood
|
|
8
|
+
round exist purely because a safety fix landed in one and not the other, and every rule
|
|
9
|
+
below would otherwise have to be written twice.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import stat as _stat
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from importlib.resources import files
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from syncade.exit_codes import CLI_USAGE_ERROR, SUCCESS, WORKTREE_ERROR
|
|
25
|
+
|
|
26
|
+
_VALID = ("all", "claude", "codex")
|
|
27
|
+
|
|
28
|
+
#: Written into each destination: the sha256 of every file this installer put there.
|
|
29
|
+
#: It is a RECORD, not a marker. A marker ("syncade was here") cannot tell an upgrade from
|
|
30
|
+
#: an edit — both PR-h-04 attempts that used one failed a blind panel. Hashes can: a file
|
|
31
|
+
#: whose bytes match what we recorded is ours whatever version wrote it, and a file whose
|
|
32
|
+
#: bytes match nothing we recorded is the operator's whatever it is named.
|
|
33
|
+
#:
|
|
34
|
+
#: **Its trust boundary, stated rather than implied** (PR-h-04.5 item 4). The record is not
|
|
35
|
+
#: an authentication token and cannot be one: syncade holds no secret, so a record that
|
|
36
|
+
#: parses is believed. That is bounded, and the bound is what makes it acceptable —
|
|
37
|
+
#: anyone able to edit the record already has write access to the destination and could
|
|
38
|
+
#: delete those files directly, so it confers no capability its writer did not already
|
|
39
|
+
#: have. What it must never do is reach FURTHER than that, which is why keys are
|
|
40
|
+
#: constrained to plain filenames and are used only for lookup, never as paths.
|
|
41
|
+
#:
|
|
42
|
+
#: It is also NOT exempt from the survey (the round-4 finding: "the sentinel was excluded
|
|
43
|
+
#: from its own loss detection"). It is skipped only when its self-hash verifies — a
|
|
44
|
+
#: parseable ``{"files": {…}}`` is necessary but not sufficient. The manifest stores the
|
|
45
|
+
#: SHA256 of its own inner dict (bundle hashes only, without the self-hash entry) under
|
|
46
|
+
#: its own filename key; ``_scan_casualties`` re-derives that hash from the current bytes
|
|
47
|
+
#: and skips the manifest only on a match. A parseable operator edit to any inner hash
|
|
48
|
+
#: value invalidates the self-hash and causes the manifest to be flagged as a casualty.
|
|
49
|
+
_MANIFEST = ".syncade-install.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _sha(data: bytes) -> str:
|
|
53
|
+
return hashlib.sha256(data).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _no_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
57
|
+
"""``object_pairs_hook`` for ``json.loads`` that rejects duplicate object names.
|
|
58
|
+
|
|
59
|
+
A manifest with a duplicate key (e.g. two ``"README.md"`` entries) would be collapsed
|
|
60
|
+
silently by Python's default parser — last value wins, canonical dict unchanged, self-hash
|
|
61
|
+
still verifies, operator edit overwritten at exit 0. Raising here lets the caller's
|
|
62
|
+
``except ValueError`` catch it and return ``{}``, so the manifest is treated as unknown.
|
|
63
|
+
"""
|
|
64
|
+
seen: dict[str, object] = {}
|
|
65
|
+
for k, v in pairs:
|
|
66
|
+
if k in seen:
|
|
67
|
+
raise ValueError(f"duplicate JSON key: {k!r}")
|
|
68
|
+
seen[k] = v
|
|
69
|
+
return seen
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_manifest(raw: bytes) -> dict[str, str]:
|
|
73
|
+
"""Parse manifest bytes and verify the self-hash; return the verified prior dict or ``{}``.
|
|
74
|
+
|
|
75
|
+
The returned dict includes ``{_MANIFEST: inner_sha}`` when the self-hash verifies.
|
|
76
|
+
``_scan_casualties`` uses that entry to confirm the manifest is our unmodified record.
|
|
77
|
+
Old-style manifests (no self-hash entry) return only the inner dict; the missing
|
|
78
|
+
``_MANIFEST`` key causes ``_scan_casualties`` to flag the manifest as a casualty.
|
|
79
|
+
|
|
80
|
+
**Fails closed on any non-plain key, non-string value, or duplicate key**, rather than
|
|
81
|
+
filtering or collapsing them silently. Silent filtering or last-wins collapsing would let
|
|
82
|
+
the self-hash verify against a canonical dict that no longer represents the file's actual
|
|
83
|
+
content: an operator who added ``"../note": "value"``, ``"key": 42``, or a duplicate
|
|
84
|
+
``"README.md"`` before the real one would find their edit invisible to the self-hash
|
|
85
|
+
check, so the manifest would pass as ours and be silently overwritten at exit 0.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads(raw.decode("utf-8"), object_pairs_hook=_no_duplicate_keys)
|
|
89
|
+
if set(data.keys()) != {"files"}:
|
|
90
|
+
return {}
|
|
91
|
+
recorded = data["files"]
|
|
92
|
+
if not isinstance(recorded, dict):
|
|
93
|
+
return {}
|
|
94
|
+
# Every key and value must be a string. Non-string filtering would silently hide the
|
|
95
|
+
# entry from the self-hash check, making the self-hash verify against edited content.
|
|
96
|
+
if not all(isinstance(k, str) and isinstance(v, str) for k, v in recorded.items()):
|
|
97
|
+
return {}
|
|
98
|
+
raw_self = recorded.get(_MANIFEST)
|
|
99
|
+
inner = {k: v for k, v in recorded.items() if k != _MANIFEST}
|
|
100
|
+
# All non-manifest keys must be plain filenames. Filtering traversal keys silently
|
|
101
|
+
# has the same defect: self-hash verifies against a dict that hides the edit.
|
|
102
|
+
if any(not _is_plain_name(k) for k in inner):
|
|
103
|
+
return {}
|
|
104
|
+
if isinstance(raw_self, str):
|
|
105
|
+
canonical = json.dumps({"files": inner}, indent=2) + "\n"
|
|
106
|
+
if _sha(canonical.encode()) == raw_self:
|
|
107
|
+
# Verify raw bytes match the expected canonical form. A lexical edit
|
|
108
|
+
# (extra whitespace, alternative JSON escaping) preserves the parsed dict
|
|
109
|
+
# and inner hash, so the self-hash alone cannot detect it — the operator's
|
|
110
|
+
# edit looks identical to our canonical form after re-parsing. Byte
|
|
111
|
+
# comparison closes the gap: any deviation from canonical means operator.
|
|
112
|
+
expected = (
|
|
113
|
+
json.dumps({"files": {**inner, _MANIFEST: raw_self}}, indent=2) + "\n"
|
|
114
|
+
).encode()
|
|
115
|
+
if raw != expected:
|
|
116
|
+
return {} # lexical edit: raw bytes diverge from canonical form
|
|
117
|
+
return {**inner, _MANIFEST: raw_self}
|
|
118
|
+
return {} # tampered: inner changed but self-hash not updated
|
|
119
|
+
return inner # old-style manifest; _MANIFEST absent from returned dict
|
|
120
|
+
except (ValueError, KeyError, AttributeError, TypeError):
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_manifest(dest: Path) -> dict[str, str]:
|
|
125
|
+
"""``{filename: sha256}`` this installer last wrote here, or ``{}`` if unknown.
|
|
126
|
+
|
|
127
|
+
Unreadable or malformed reads as ``{}``, which means "we can prove nothing", which
|
|
128
|
+
means every differing file is treated as the operator's. Failing closed here costs one
|
|
129
|
+
refusal; failing open costs their file.
|
|
130
|
+
|
|
131
|
+
A parseable file with ANY extra top-level keys (e.g. an operator-added
|
|
132
|
+
``"operator_note"``) also reads as ``{}`` — the manifest must have exactly the shape
|
|
133
|
+
``{"files": {…}}`` this installer writes.
|
|
134
|
+
|
|
135
|
+
**lstats before reading.** A FIFO at the manifest path would block forever on
|
|
136
|
+
``read_bytes``; a symlink could redirect to a blocking target. Both bypass the
|
|
137
|
+
``_scan_casualties`` special-file protections for the manifest path specifically.
|
|
138
|
+
Returning ``{}`` here lets ``_scan_casualties`` classify the entry normally.
|
|
139
|
+
"""
|
|
140
|
+
mpath = dest / _MANIFEST
|
|
141
|
+
try:
|
|
142
|
+
st = mpath.lstat()
|
|
143
|
+
except OSError:
|
|
144
|
+
return {}
|
|
145
|
+
if not _stat.S_ISREG(st.st_mode):
|
|
146
|
+
return {}
|
|
147
|
+
try:
|
|
148
|
+
return _parse_manifest(mpath.read_bytes())
|
|
149
|
+
except OSError:
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_plain_name(key: str) -> bool:
|
|
154
|
+
"""A record key must be a bare filename — no separator, no ``..``, not absolute.
|
|
155
|
+
|
|
156
|
+
The bundle is flat, so those are the only keys this installer ever writes. Today the
|
|
157
|
+
keys are used ONLY for dictionary lookup against names derived from walking the
|
|
158
|
+
destination, so a crafted `../../../.bashrc` already matches nothing — measured. This
|
|
159
|
+
makes that safety structural instead of incidental: if a future change ever joins a key
|
|
160
|
+
onto a path, the traversal it would open is already closed here.
|
|
161
|
+
"""
|
|
162
|
+
return bool(key) and key not in (".", "..") and "/" not in key and not Path(key).is_absolute()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _dest(harness: str, home: Path, codex_home: Path) -> Path:
|
|
166
|
+
if harness == "claude":
|
|
167
|
+
return home / ".claude" / "skills" / "syncade"
|
|
168
|
+
return codex_home / "skills" / "syncade"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _bundle(harness: str) -> dict[str, bytes]:
|
|
172
|
+
"""The files this installer would write, by name."""
|
|
173
|
+
src = files("syncade") / "skills" / harness
|
|
174
|
+
return {e.name: e.read_bytes() for e in src.iterdir() if e.is_file()}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _scan_casualties(
|
|
178
|
+
root: Path,
|
|
179
|
+
current: Path,
|
|
180
|
+
bundle: dict[str, bytes],
|
|
181
|
+
prior: dict[str, str],
|
|
182
|
+
out: list[str],
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Walk ``current`` and append casualty descriptions to ``out``.
|
|
185
|
+
|
|
186
|
+
``root`` is the top-level destination — used to compute names relative to it for
|
|
187
|
+
bundle/prior lookup. Does NOT report ``current`` itself; only its contents.
|
|
188
|
+
|
|
189
|
+
Uses ``os.scandir`` rather than ``rglob`` so that unreadable subdirectories are
|
|
190
|
+
explicitly caught (``rglob`` silently skips them, dropping their contents from the
|
|
191
|
+
survey). Special files (FIFOs, sockets) are classified by stat mode without opening
|
|
192
|
+
them — ``read_bytes`` on a FIFO blocks indefinitely. Empty subdirectories are reported
|
|
193
|
+
as casualties because ``rmtree`` removes them along with everything else.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
with os.scandir(current) as it:
|
|
197
|
+
entries = sorted(it, key=lambda e: e.name)
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
out.append(f"{current} — would be LOST (directory unreadable: {exc})")
|
|
200
|
+
return
|
|
201
|
+
for entry in entries:
|
|
202
|
+
path = Path(entry.path)
|
|
203
|
+
try:
|
|
204
|
+
st = entry.stat(follow_symlinks=False)
|
|
205
|
+
except OSError:
|
|
206
|
+
out.append(f"{path} — would be LOST (unreadable, cannot be proven syncade's)")
|
|
207
|
+
continue
|
|
208
|
+
mode = st.st_mode
|
|
209
|
+
if _stat.S_ISLNK(mode):
|
|
210
|
+
# A symlink INSIDE the destination is not the same as a symlinked DESTINATION,
|
|
211
|
+
# and the "symlinks are not loss" rule must not be stretched to cover it.
|
|
212
|
+
out.append(f"{path} — would be DELETED (a symlink you made; its target is untouched)")
|
|
213
|
+
elif _stat.S_ISDIR(mode):
|
|
214
|
+
# Bundle is flat; any subdirectory is operator-added and rmtree removes it.
|
|
215
|
+
# Recurse so its contents are individually reported as casualties.
|
|
216
|
+
prev = len(out)
|
|
217
|
+
_scan_casualties(root, path, bundle, prior, out)
|
|
218
|
+
if len(out) == prev:
|
|
219
|
+
# Recursion added nothing: directory scanned successfully with 0 entries.
|
|
220
|
+
out.append(
|
|
221
|
+
f"{path}/ — would be DELETED (an empty directory syncade did not create)"
|
|
222
|
+
)
|
|
223
|
+
elif not _stat.S_ISREG(mode):
|
|
224
|
+
# FIFO, socket, device file — do not open; classify by type without reading.
|
|
225
|
+
out.append(f"{path} — would be DELETED (a special file syncade did not create)")
|
|
226
|
+
else:
|
|
227
|
+
name = str(path.relative_to(root))
|
|
228
|
+
try:
|
|
229
|
+
data = path.read_bytes()
|
|
230
|
+
except OSError:
|
|
231
|
+
out.append(f"{path} — would be LOST (unreadable, cannot be proven syncade's)")
|
|
232
|
+
continue
|
|
233
|
+
if name == _MANIFEST:
|
|
234
|
+
# Recognised by self-hash verification, not just by parsing. prior[_MANIFEST]
|
|
235
|
+
# is set only when _read_manifest verified self-consistency. An operator edit
|
|
236
|
+
# that keeps valid JSON invalidates the self-hash, causing _parse_manifest to
|
|
237
|
+
# return {} and prior[_MANIFEST] to be absent — so the manifest is flagged.
|
|
238
|
+
self_hash = prior.get(_MANIFEST)
|
|
239
|
+
if self_hash is not None and _parse_manifest(data).get(_MANIFEST) == self_hash:
|
|
240
|
+
continue
|
|
241
|
+
if bundle.get(name) == data:
|
|
242
|
+
continue # byte-identical to what we would write: rewriting changes nothing
|
|
243
|
+
if prior.get(name) == _sha(data):
|
|
244
|
+
continue # WE wrote these exact bytes on a previous install — an upgrade
|
|
245
|
+
verb = "OVERWRITTEN" if name in bundle else "DELETED"
|
|
246
|
+
why = (
|
|
247
|
+
"differs from the bundled copy"
|
|
248
|
+
if name in bundle
|
|
249
|
+
else "not part of the syncade skill"
|
|
250
|
+
)
|
|
251
|
+
out.append(f"{path} — would be {verb} ({why}, and syncade did not write it)")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _casualties(dest: Path, bundle: dict[str, bytes], prior: dict[str, str]) -> list[str]:
|
|
255
|
+
"""Everything under ``dest`` that installing would DESTROY, described for a human.
|
|
256
|
+
|
|
257
|
+
The installer replaces the destination wholesale, so "destroy" means two things: a file
|
|
258
|
+
that is not part of the bundle gets DELETED, and a bundled name whose bytes differ gets
|
|
259
|
+
OVERWRITTEN. Both are losses; neither was announced before PR-h-04.5.
|
|
260
|
+
|
|
261
|
+
A SYMLINKED destination is not loss and returns nothing. The README documents
|
|
262
|
+
symlink-to-checkout, re-running the installer over it has always replaced the link, and
|
|
263
|
+
unlinking a symlink destroys nothing — the checkout it pointed at is untouched. Refusing
|
|
264
|
+
there would break a documented path to protect data that was never at risk.
|
|
265
|
+
|
|
266
|
+
Scanned RECURSIVELY via ``_scan_casualties``: the bundle is flat, but ``rmtree`` takes
|
|
267
|
+
whole subdirectories, so an operator's ``notes/`` directory is exactly as destroyed as
|
|
268
|
+
their loose file. Empty and unreadable subdirectories are both reported as casualties.
|
|
269
|
+
Non-regular entries (FIFOs, sockets) are classified without opening them.
|
|
270
|
+
"""
|
|
271
|
+
if dest.is_symlink():
|
|
272
|
+
return []
|
|
273
|
+
if not dest.exists():
|
|
274
|
+
return []
|
|
275
|
+
if dest.is_file():
|
|
276
|
+
# A regular file at the skill destination path will be unlinked and replaced with
|
|
277
|
+
# a directory. This is not a documented usage; a file here cannot be proven
|
|
278
|
+
# installer-owned, so it is always a casualty.
|
|
279
|
+
return [f"{dest} — would be DELETED (a regular file where the skill directory is expected)"]
|
|
280
|
+
out: list[str] = []
|
|
281
|
+
_scan_casualties(dest, dest, bundle, prior, out)
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _restore(dest: Path, backup: Path) -> None:
|
|
286
|
+
"""Move ``backup`` back to ``dest``, clearing anything syncade put in the way.
|
|
287
|
+
|
|
288
|
+
Restoring must not be blocked by an obstruction SYNCADE created. Reproduced: with
|
|
289
|
+
CODEX_HOME inside the claude destination, preparing codex made directories under a path
|
|
290
|
+
claude had already renamed to its backup, so ``dest`` existed again and rollback declined
|
|
291
|
+
— leaving the operator's working skill deleted and their backup stranded under a hidden
|
|
292
|
+
name. **A rollback that refuses to roll back turns a recoverable failure into data loss**,
|
|
293
|
+
which is worse than having no rollback at all.
|
|
294
|
+
|
|
295
|
+
Anything at ``dest`` now post-dates the backup being taken, so it cannot be the
|
|
296
|
+
operator's original — that was moved to ``backup``. Clear it, then restore. If even that
|
|
297
|
+
fails, say where the backup is rather than failing silently.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
if dest.is_symlink() or dest.is_file():
|
|
301
|
+
dest.unlink(missing_ok=True)
|
|
302
|
+
elif dest.is_dir():
|
|
303
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
304
|
+
backup.rename(dest)
|
|
305
|
+
except OSError:
|
|
306
|
+
print(
|
|
307
|
+
f"[syncade] could not restore {dest} — your previous skill is preserved at "
|
|
308
|
+
f"{backup}; move it back by hand.",
|
|
309
|
+
file=sys.stderr,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def install_skill(
|
|
314
|
+
target: str = "all",
|
|
315
|
+
*,
|
|
316
|
+
home: Path | None = None,
|
|
317
|
+
codex_home: Path | None = None,
|
|
318
|
+
force: bool = False,
|
|
319
|
+
) -> int:
|
|
320
|
+
"""Copy the bundled skill into the harness skill directories. Returns a CLI exit code.
|
|
321
|
+
|
|
322
|
+
**Refuses rather than destroying.** Before PR-h-04.5 this ran ``rmtree`` over the
|
|
323
|
+
destination and rewrote it: measured on a real tree, a hand-edited ``SKILL.md`` was
|
|
324
|
+
overwritten and two unrelated operator files were deleted, at exit 0 with no prompt,
|
|
325
|
+
warning or backup. Now every file that installing would destroy is surveyed FIRST; if
|
|
326
|
+
there are any, the install refuses (exit 60), names each one and what would happen to
|
|
327
|
+
it, and points at ``--force-install``.
|
|
328
|
+
|
|
329
|
+
``force=True`` restores the old destructive behaviour deliberately. It is informed
|
|
330
|
+
consent, not a safe mode — the casualties are still printed.
|
|
331
|
+
"""
|
|
332
|
+
if target not in _VALID:
|
|
333
|
+
print(
|
|
334
|
+
f"[syncade] unknown --install-skill target {target!r}; expected one of {_VALID}",
|
|
335
|
+
file=sys.stderr,
|
|
336
|
+
)
|
|
337
|
+
return CLI_USAGE_ERROR
|
|
338
|
+
|
|
339
|
+
home = home or Path.home()
|
|
340
|
+
if codex_home is None:
|
|
341
|
+
env_cx = os.environ.get("CODEX_HOME")
|
|
342
|
+
codex_home = Path(env_cx) if env_cx else home / ".codex"
|
|
343
|
+
|
|
344
|
+
harnesses = ["claude", "codex"] if target == "all" else [target]
|
|
345
|
+
plan = [
|
|
346
|
+
(h, d, _bundle(h), _read_manifest(d))
|
|
347
|
+
for h, d in ((h, _dest(h, home, codex_home)) for h in harnesses)
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
# Refuse if any two harnesses resolve to the same destination. Duplicate destinations
|
|
351
|
+
# arise when CODEX_HOME == ~/.claude (or a symlink that resolves there): phase 1 stages
|
|
352
|
+
# both, then phase 2 crashes renaming the second tree over the already-populated dest
|
|
353
|
+
# and leaves staging artifacts behind. Detecting this before any I/O keeps it clean.
|
|
354
|
+
if len(plan) > 1:
|
|
355
|
+
# OVERLAP, not just equality. Equal destinations were the obvious case; CONTAINMENT
|
|
356
|
+
# is the damaging one, and it was reproduced: with CODEX_HOME inside the claude
|
|
357
|
+
# destination, preparing codex creates directories underneath a path claude had
|
|
358
|
+
# already renamed to a backup, phase 2 then cannot rename its staged tree over the
|
|
359
|
+
# now-non-empty destination, and rollback declined to restore because "the
|
|
360
|
+
# destination exists". Exit 60 with the operator's working skill GONE and the backup
|
|
361
|
+
# stranded under a hidden name — a refusal that destroys, which is the worst outcome
|
|
362
|
+
# this PR can produce. One harness's directory tree must never sit inside another's.
|
|
363
|
+
resolved = [(h, Path(str(d.resolve()))) for h, d, _, _ in plan]
|
|
364
|
+
for i, (h_a, a) in enumerate(resolved):
|
|
365
|
+
for h_b, b in resolved[i + 1 :]:
|
|
366
|
+
if a == b or a in b.parents or b in a.parents:
|
|
367
|
+
relation = (
|
|
368
|
+
"are the same directory" if a == b else "overlap — one contains the other"
|
|
369
|
+
)
|
|
370
|
+
print(
|
|
371
|
+
f"[syncade] cannot install 'all': the {h_a!r} and {h_b!r} destinations "
|
|
372
|
+
f"{relation}:\n {h_a}: {a}\n {h_b}: {b}\n"
|
|
373
|
+
"Installing both would have one harness write inside the other. Set "
|
|
374
|
+
"CODEX_HOME to a directory outside ~/.claude, or install one harness "
|
|
375
|
+
"at a time.",
|
|
376
|
+
file=sys.stderr,
|
|
377
|
+
)
|
|
378
|
+
return WORKTREE_ERROR
|
|
379
|
+
|
|
380
|
+
# Survey EVERY destination before writing ANY of them. The survey runs even under
|
|
381
|
+
# --force: consent to destroying files is not consent to being told nothing about
|
|
382
|
+
# which ones. Skipping it there would also make this function's own docstring false.
|
|
383
|
+
losses = [line for _, dest, bundle, prior in plan for line in _casualties(dest, bundle, prior)]
|
|
384
|
+
if losses:
|
|
385
|
+
listing = "\n".join(f" {line}" for line in losses)
|
|
386
|
+
if not force:
|
|
387
|
+
print(
|
|
388
|
+
f"[syncade] refusing to install: {len(losses)} file(s) would be lost.\n"
|
|
389
|
+
f"{listing}\n\n"
|
|
390
|
+
"syncade did not write these and will not destroy them. Move or delete them "
|
|
391
|
+
"yourself, or re-run with --force-install to overwrite deliberately.",
|
|
392
|
+
file=sys.stderr,
|
|
393
|
+
)
|
|
394
|
+
return WORKTREE_ERROR
|
|
395
|
+
print(
|
|
396
|
+
f"[syncade] --force-install: destroying {len(losses)} file(s) syncade did not "
|
|
397
|
+
f"write.\n{listing}",
|
|
398
|
+
file=sys.stderr,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# PHASE 1 — build every harness beside its destination, touching none of them.
|
|
402
|
+
#
|
|
403
|
+
# Two improvements over the previous version:
|
|
404
|
+
#
|
|
405
|
+
# (a) tempfile.mkdtemp produces a unique staging directory so a pre-existing path
|
|
406
|
+
# named ".<dest>.syncade-new" (operator content, stale run) is never deleted
|
|
407
|
+
# before ownership is proven. The old fixed name was blindly rmtree'd.
|
|
408
|
+
#
|
|
409
|
+
# (b) Any existing destination is RENAMED to a backup path before phase 2, proving
|
|
410
|
+
# it is removable while still reversible. The previous approach called
|
|
411
|
+
# shutil.rmtree in phase 2 AFTER the first harness was already swapped — a
|
|
412
|
+
# read-only destination (or one with a protected subdirectory) raised an
|
|
413
|
+
# uncaught PermissionError and left a half-applied install. Rename needs only
|
|
414
|
+
# write permission on the PARENT directory, not on the destination itself, so
|
|
415
|
+
# it succeeds in cases where rmtree would fail.
|
|
416
|
+
staged: list[tuple[str, Path, Path, Path | None]] = []
|
|
417
|
+
_current_tmp: Path | None = None # staging dir created but not yet in staged
|
|
418
|
+
try:
|
|
419
|
+
for harness, dest, bundle, _prior in plan:
|
|
420
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
421
|
+
_current_tmp = Path(
|
|
422
|
+
tempfile.mkdtemp(dir=dest.parent, prefix=f".{dest.name}.syncade-new-")
|
|
423
|
+
)
|
|
424
|
+
tmp = _current_tmp
|
|
425
|
+
for name, data in bundle.items():
|
|
426
|
+
(tmp / name).write_bytes(data)
|
|
427
|
+
# The record of what we wrote, so the NEXT install can tell an upgrade from an
|
|
428
|
+
# edit. Without it every version bump refuses (measured: exit 60 on an ordinary
|
|
429
|
+
# upgrade), which trains operators to pass --force-install reflexively and loses
|
|
430
|
+
# the protection entirely — rule 5's failure mode.
|
|
431
|
+
#
|
|
432
|
+
# The manifest also records its own SHA256 (the self-hash) so _scan_casualties
|
|
433
|
+
# can verify it is unmodified on re-install. The self-hash is the SHA of the
|
|
434
|
+
# INNER canonical form (bundle hashes only, without the self-hash entry itself),
|
|
435
|
+
# breaking the circularity: compute inner → hash it → embed that hash.
|
|
436
|
+
inner_files = {n: _sha(d) for n, d in bundle.items()}
|
|
437
|
+
inner_sha = _sha((json.dumps({"files": inner_files}, indent=2) + "\n").encode())
|
|
438
|
+
(tmp / _MANIFEST).write_text(
|
|
439
|
+
json.dumps({"files": {**inner_files, _MANIFEST: inner_sha}}, indent=2) + "\n",
|
|
440
|
+
encoding="utf-8",
|
|
441
|
+
)
|
|
442
|
+
# Move any existing destination to a UNIQUE backup path — proves it is removable
|
|
443
|
+
# while still reversible. The path is unique (mkdtemp-derived) so an operator file
|
|
444
|
+
# that happens to sit at the old predictable ".syncade-old" path is never deleted
|
|
445
|
+
# before ownership is proven. Rename needs only parent write permission, so a
|
|
446
|
+
# read-only destination that would block shutil.rmtree is still movable.
|
|
447
|
+
backup: Path | None = None
|
|
448
|
+
if dest.exists() or dest.is_symlink():
|
|
449
|
+
_bk = Path(tempfile.mkdtemp(dir=dest.parent, prefix=f".{dest.name}.syncade-old-"))
|
|
450
|
+
_bk.rmdir() # free the path before rename; race is within the parent we control
|
|
451
|
+
backup = _bk
|
|
452
|
+
dest.rename(backup) # raises OSError if dest cannot be moved
|
|
453
|
+
staged.append((harness, dest, tmp, backup))
|
|
454
|
+
_current_tmp = None # now tracked by staged; don't double-clean on exception
|
|
455
|
+
except OSError as exc:
|
|
456
|
+
# Clean up any staging dir created for the current harness but not yet in staged.
|
|
457
|
+
if _current_tmp is not None:
|
|
458
|
+
shutil.rmtree(_current_tmp, ignore_errors=True)
|
|
459
|
+
# Restore any destinations already moved to backup, then remove staged dirs.
|
|
460
|
+
for _, d, t, bk in staged:
|
|
461
|
+
shutil.rmtree(t, ignore_errors=True)
|
|
462
|
+
if bk is not None:
|
|
463
|
+
_restore(d, bk)
|
|
464
|
+
print(
|
|
465
|
+
f"[syncade] could not prepare the skill install: {exc}\n"
|
|
466
|
+
"Nothing was changed — no harness was modified.",
|
|
467
|
+
file=sys.stderr,
|
|
468
|
+
)
|
|
469
|
+
return WORKTREE_ERROR
|
|
470
|
+
|
|
471
|
+
# PHASE 2 — rename each staged tree into place. Phase 1 already moved every prior
|
|
472
|
+
# destination to a backup, so only renames remain here — no rmtree of the old dest.
|
|
473
|
+
# Wrapped in try/except so a mid-phase failure (e.g. a race that repopulates the dest
|
|
474
|
+
# between phase 1 and phase 2) rolls back cleanly rather than leaving a partial install.
|
|
475
|
+
completed: list[tuple[str, Path, Path, Path | None]] = []
|
|
476
|
+
try:
|
|
477
|
+
for harness, dest, tmp, backup in staged:
|
|
478
|
+
tmp.rename(dest)
|
|
479
|
+
completed.append((harness, dest, tmp, backup))
|
|
480
|
+
print(f"[syncade] installed {harness} skill -> {dest}", file=sys.stderr)
|
|
481
|
+
except OSError as exc:
|
|
482
|
+
# Undo every successfully placed tree and restore its backup; remove remaining
|
|
483
|
+
# staged dirs. Best-effort: an operator can recover any surviving backup manually.
|
|
484
|
+
completed_dests = {d for _, d, _, _ in completed}
|
|
485
|
+
for _, d, t, bk in staged:
|
|
486
|
+
if d in completed_dests:
|
|
487
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
488
|
+
else:
|
|
489
|
+
shutil.rmtree(t, ignore_errors=True)
|
|
490
|
+
if bk is not None:
|
|
491
|
+
_restore(d, bk)
|
|
492
|
+
print(
|
|
493
|
+
f"[syncade] could not complete the skill install: {exc}\n"
|
|
494
|
+
"Rolling back — attempting to restore the previous state.",
|
|
495
|
+
file=sys.stderr,
|
|
496
|
+
)
|
|
497
|
+
return WORKTREE_ERROR
|
|
498
|
+
|
|
499
|
+
# Clean up backups now that all swaps succeeded. ignore_errors because a backup
|
|
500
|
+
# with a protected subdirectory (0o555) may not fully rmtree; leaving it behind
|
|
501
|
+
# is preferred over blocking the install on cleanup.
|
|
502
|
+
# is_symlink() must precede is_dir(): Path.is_dir() follows symlinks, so a symlink
|
|
503
|
+
# to a directory evaluates to True and shutil.rmtree refuses the symlink silently,
|
|
504
|
+
# leaving a stale .syncade.syncade-old-* symlink in the skills directory.
|
|
505
|
+
for _, _, _, backup in staged:
|
|
506
|
+
if backup is None:
|
|
507
|
+
pass
|
|
508
|
+
elif backup.is_symlink() or backup.is_file():
|
|
509
|
+
backup.unlink(missing_ok=True)
|
|
510
|
+
elif backup.is_dir():
|
|
511
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
512
|
+
|
|
513
|
+
print("[syncade] done — restart your harness to pick up the skill.", file=sys.stderr)
|
|
514
|
+
return SUCCESS
|