claudekit-codex-sync 0.1.0 → 0.2.1

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 (35) hide show
  1. package/AGENTS.md +5 -8
  2. package/README.md +76 -59
  3. package/docs/codebase-summary.md +40 -43
  4. package/docs/codex-vs-claude-agents.md +53 -42
  5. package/docs/installation-guide.md +67 -23
  6. package/docs/project-overview-pdr.md +20 -26
  7. package/docs/project-roadmap.md +26 -32
  8. package/docs/system-architecture.md +45 -82
  9. package/package.json +10 -4
  10. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-01-delete-dead-code.md +88 -0
  11. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-02-cli-redesign.md +316 -0
  12. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-03-symlink-venv.md +148 -0
  13. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-04-wire-unused-functions.md +151 -0
  14. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-05-safety-tests-docs.md +206 -0
  15. package/plans/260223-1310-v02-cli-redesign-cleanup/plan.md +18 -0
  16. package/plans/reports/planner-260223-v02-cli-redesign-cleanup-validation.md +95 -0
  17. package/plans/reports/project-manager-260223-v02-cli-redesign-cleanup-finalization.md +28 -0
  18. package/src/claudekit_codex_sync/asset_sync_dir.py +60 -9
  19. package/src/claudekit_codex_sync/asset_sync_zip.py +16 -5
  20. package/src/claudekit_codex_sync/clean_target.py +59 -0
  21. package/src/claudekit_codex_sync/cli.py +113 -81
  22. package/src/claudekit_codex_sync/constants.py +2 -13
  23. package/src/claudekit_codex_sync/dep_bootstrapper.py +82 -36
  24. package/src/claudekit_codex_sync/path_normalizer.py +2 -0
  25. package/src/claudekit_codex_sync/runtime_verifier.py +17 -7
  26. package/src/claudekit_codex_sync/source_resolver.py +10 -2
  27. package/src/claudekit_codex_sync/sync_registry.py +1 -0
  28. package/templates/agents-md.md +5 -8
  29. package/tests/test_clean_target.py +75 -0
  30. package/tests/test_cli_args.py +57 -0
  31. package/reports/brainstorm-260222-2051-claudekit-codex-community-sync.md +0 -113
  32. package/scripts/bootstrap-claudekit-skill-scripts.sh +0 -150
  33. package/scripts/claudekit-sync-all.py +0 -1150
  34. package/scripts/export-claudekit-prompts.sh +0 -221
  35. package/scripts/normalize-claudekit-for-codex.sh +0 -261
@@ -0,0 +1,206 @@
1
+ # Phase 5 — Safety Fixes + Tests + Docs
2
+
3
+ ## Overview
4
+ - Priority: P1
5
+ - Status: [x] Implemented (release ops pending)
6
+ - Estimated: 30 min
7
+
8
+ ## Code Changes
9
+
10
+ ### [MODIFY] `src/claudekit_codex_sync/path_normalizer.py`
11
+
12
+ **Add defensive mkdir in `convert_agents_md_to_toml()` (after line ~113):**
13
+
14
+ ```python
15
+ def convert_agents_md_to_toml(*, codex_home: Path, dry_run: bool) -> int:
16
+ """Convert ClaudeKit agent .md files to Codex .toml format."""
17
+ from .constants import (
18
+ CLAUDE_MODEL_REASONING_EFFORT,
19
+ CLAUDE_TO_CODEX_MODELS,
20
+ READ_ONLY_AGENT_ROLES,
21
+ )
22
+
23
+ agents_dir = codex_home / "agents"
24
+ if not agents_dir.exists():
25
+ return 0
26
+
27
+ # Defensive: ensure target dir exists for writing .toml
28
+ if not dry_run:
29
+ agents_dir.mkdir(parents=True, exist_ok=True)
30
+
31
+ # ... rest unchanged
32
+ ```
33
+
34
+ ### [NEW] `tests/test_clean_target.py`
35
+
36
+ ```python
37
+ """Tests for clean_target module."""
38
+
39
+ import json
40
+ from pathlib import Path
41
+
42
+ from claudekit_codex_sync.clean_target import clean_target
43
+
44
+
45
+ def test_clean_removes_agents(tmp_path: Path):
46
+ """Clean removes agents dir."""
47
+ agents = tmp_path / "agents"
48
+ agents.mkdir()
49
+ (agents / "planner.toml").write_text("model = 'test'")
50
+ (agents / "researcher.toml").write_text("model = 'test'")
51
+
52
+ removed = clean_target(tmp_path, dry_run=False)
53
+ assert not agents.exists()
54
+ assert removed >= 2
55
+
56
+
57
+ def test_clean_keeps_venv(tmp_path: Path):
58
+ """Clean keeps skills/.venv intact."""
59
+ skills = tmp_path / "skills"
60
+ skills.mkdir()
61
+ venv = skills / ".venv"
62
+ venv.mkdir()
63
+ (venv / "bin").mkdir()
64
+ (venv / "bin" / "python3").write_text("#!/usr/bin/env python3")
65
+ skill = skills / "my-skill"
66
+ skill.mkdir()
67
+ (skill / "SKILL.md").write_text("# test")
68
+
69
+ clean_target(tmp_path, dry_run=False)
70
+ assert venv.exists(), ".venv should survive cleaning"
71
+ assert not skill.exists(), "skill dirs should be removed"
72
+
73
+
74
+ def test_clean_dry_run(tmp_path: Path):
75
+ """Dry run counts but doesn't delete."""
76
+ agents = tmp_path / "agents"
77
+ agents.mkdir()
78
+ (agents / "test.toml").write_text("x = 1")
79
+
80
+ removed = clean_target(tmp_path, dry_run=True)
81
+ assert removed >= 1
82
+ assert agents.exists(), "dry-run should not delete"
83
+
84
+
85
+ def test_clean_removes_registry(tmp_path: Path):
86
+ """Clean clears sync registry."""
87
+ registry = tmp_path / ".claudekit-sync-registry.json"
88
+ registry.write_text(json.dumps({"version": 1}))
89
+
90
+ clean_target(tmp_path, dry_run=False)
91
+ assert not registry.exists()
92
+ ```
93
+
94
+ ### [NEW] `tests/test_cli_args.py`
95
+
96
+ ```python
97
+ """Tests for CLI argument parsing."""
98
+
99
+ import sys
100
+ from unittest.mock import patch
101
+
102
+ from claudekit_codex_sync.cli import parse_args
103
+
104
+
105
+ def test_default_args():
106
+ """Default: project scope, no flags."""
107
+ with patch.object(sys, "argv", ["ckc-sync"]):
108
+ args = parse_args()
109
+ assert not args.global_scope
110
+ assert not args.fresh
111
+ assert not args.force
112
+ assert not args.mcp
113
+ assert not args.no_deps
114
+ assert not args.dry_run
115
+ assert args.zip_path is None
116
+ assert args.source is None
117
+
118
+
119
+ def test_global_flag():
120
+ """'-g' enables global scope."""
121
+ with patch.object(sys, "argv", ["ckc-sync", "-g"]):
122
+ args = parse_args()
123
+ assert args.global_scope
124
+
125
+
126
+ def test_fresh_flag():
127
+ """'-f' enables fresh clean."""
128
+ with patch.object(sys, "argv", ["ckc-sync", "-f"]):
129
+ args = parse_args()
130
+ assert args.fresh
131
+
132
+
133
+ def test_combined_short_flags():
134
+ """'-g -f -n' all work together."""
135
+ with patch.object(sys, "argv", ["ckc-sync", "-g", "-f", "-n"]):
136
+ args = parse_args()
137
+ assert args.global_scope
138
+ assert args.fresh
139
+ assert args.dry_run
140
+
141
+
142
+ def test_zip_flag():
143
+ """'--zip' sets zip path and implies zip mode."""
144
+ with patch.object(sys, "argv", ["ckc-sync", "--zip", "test.zip"]):
145
+ args = parse_args()
146
+ assert str(args.zip_path) == "test.zip"
147
+
148
+
149
+ def test_mcp_flag():
150
+ """'--mcp' includes MCP skills."""
151
+ with patch.object(sys, "argv", ["ckc-sync", "--mcp"]):
152
+ args = parse_args()
153
+ assert args.mcp
154
+ ```
155
+
156
+ ### [MODIFY] Docs
157
+
158
+ Update these files to reflect new CLI:
159
+ - `README.md` — new command name, flags, examples
160
+ - `docs/installation-guide.md` — new command, npm install
161
+ - `docs/codebase-summary.md` — new modules (clean_target.py), removed scripts/
162
+
163
+ **README.md Quick Start update:**
164
+ ```markdown
165
+ ## Quick Start
166
+
167
+ ```bash
168
+ npm install -g claudekit-codex-sync
169
+
170
+ # Project sync (to ./.codex/)
171
+ ckc-sync
172
+
173
+ # Global sync (to ~/.codex/)
174
+ ckc-sync -g
175
+
176
+ # Fresh re-sync
177
+ ckc-sync -g -f
178
+
179
+ # Preview
180
+ ckc-sync -g -n
181
+ ```
182
+ ```
183
+
184
+ ## Verification
185
+
186
+ ```bash
187
+ # Run all tests
188
+ PYTHONPATH=src python3 -m pytest tests/ -v
189
+
190
+ # Expected: 11 existing + 10 new = 21 tests passing
191
+
192
+ # Lint
193
+ python3 -m py_compile src/claudekit_codex_sync/*.py
194
+
195
+ # Full sync test
196
+ ckc-sync -g -f # fresh global sync
197
+ ckc-sync -n # project dry-run
198
+ ```
199
+
200
+ ## Todo
201
+ - [x] Add defensive mkdir in `path_normalizer.py`
202
+ - [x] Create `tests/test_clean_target.py` (4 tests)
203
+ - [x] Create `tests/test_cli_args.py` (6 tests)
204
+ - [x] Update `README.md`, `docs/installation-guide.md`, `docs/codebase-summary.md`
205
+ - [x] Run all tests
206
+ - [ ] `git commit && git push && npm publish`
@@ -0,0 +1,18 @@
1
+ # v0.2 Plan — CLI Redesign + Cleanup
2
+
3
+ ## Overview
4
+
5
+ | Phase | What | Files | Status |
6
+ |---|---|---|---|
7
+ | 1 | Delete dead code + trim constants | `scripts/`, `constants.py` | [x] Complete |
8
+ | 2 | CLI redesign (ckc-sync, 8 flags) | `cli.py`, `clean_target.py`, `package.json`, `bin/` | [x] Implemented (local install step pending) |
9
+ | 3 | Symlink venv | `dep_bootstrapper.py` | [x] Complete |
10
+ | 4 | Wire unused functions + backup | `cli.py`, `asset_sync_dir.py`, `sync_registry.py` | [x] Complete |
11
+ | 5 | Safety fixes + tests + docs | `path_normalizer.py`, tests, docs | [x] Implemented (release ops pending) |
12
+
13
+ Details: see phase files.
14
+
15
+ ## Tracking Notes
16
+ - Validation run on 2026-02-23: `python3 -m py_compile src/claudekit_codex_sync/*.py` passed.
17
+ - Validation run on 2026-02-23: `PYTHONPATH=src python3 -m pytest tests/ -q` passed (`21 passed`).
18
+ - Pending non-implementation ops: `npm install -g .`, `git commit && git push && npm publish`.
@@ -0,0 +1,95 @@
1
+ # Validation + Execution Checklist
2
+
3
+ Date: 2026-02-23
4
+ Plan: `plans/260223-1310-v02-cli-redesign-cleanup/`
5
+
6
+ ## Validation Verdict
7
+ Conditionally executable. Use with preflight fixes first.
8
+
9
+ ### Baseline (current repo)
10
+ - `PYTHONPATH=src python3 -m pytest tests/ -q` -> 11 passed.
11
+ - `python3 -m py_compile src/claudekit_codex_sync/*.py` -> pass.
12
+
13
+ ## Blocking Conflicts (Resolve First)
14
+ 1. **Agent pipeline mismatch (high)**
15
+ Plan says remove `agents` from assets, but conversion reads only `codex_home/agents`. Current sync writes assets under `codex_home/claudekit/*`. If `agents` removed, agent conversion/registration becomes impossible.
16
+ Refs: `plans/260223-1310-v02-cli-redesign-cleanup/phase-01-delete-dead-code.md:53`, `plans/260223-1310-v02-cli-redesign-cleanup/phase-01-delete-dead-code.md:58`, `src/claudekit_codex_sync/asset_sync_dir.py:21`, `src/claudekit_codex_sync/path_normalizer.py:83`, `src/claudekit_codex_sync/config_enforcer.py:112`.
17
+ 2. **Rules/CLAUDE removal conflicts with downstream references (high)**
18
+ Plan drops `rules` + `CLAUDE.md`, but generated `AGENTS.md` and path replacements still reference them.
19
+ Refs: `plans/260223-1310-v02-cli-redesign-cleanup/phase-01-delete-dead-code.md:53`, `plans/260223-1310-v02-cli-redesign-cleanup/phase-01-delete-dead-code.md:60`, `templates/agents-md.md:40`, `src/claudekit_codex_sync/constants.py:29`, `src/claudekit_codex_sync/constants.py:68`.
20
+ 3. **`--force` semantic collision (high)**
21
+ Phase 2 maps `--force` to `include_conflicts`, mixing overwrite semantics with conflict-skill inclusion.
22
+ Refs: `plans/260223-1310-v02-cli-redesign-cleanup/phase-02-cli-redesign.md:114`, `src/claudekit_codex_sync/cli.py:40`, `src/claudekit_codex_sync/asset_sync_dir.py:73`, `src/claudekit_codex_sync/asset_sync_zip.py:90`.
23
+ 4. **Registry wiring incomplete in plan text (medium)**
24
+ Phase 4 adds `registry`/`force` params to `sync_assets_from_dir`, but phase flow does not require passing these from CLI callsite; backup logic can remain inert.
25
+ Refs: `plans/260223-1310-v02-cli-redesign-cleanup/phase-04-wire-unused-functions.md:39`, `src/claudekit_codex_sync/cli.py:83`.
26
+ 5. **Phase-order runtime break risk (medium)**
27
+ Phase 2 removes `include_test_deps` call before Phase 3 changes function signature. If implemented separately, bootstrap call breaks.
28
+ Refs: `plans/260223-1310-v02-cli-redesign-cleanup/phase-02-cli-redesign.md:203`, `src/claudekit_codex_sync/dep_bootstrapper.py:17`.
29
+ 6. **Test count in plan is wrong (low)**
30
+ Plan expects 17 tests after adding 10; actual target should be 21 (11 existing + 10 new).
31
+ Ref: `plans/260223-1310-v02-cli-redesign-cleanup/phase-05-safety-tests-docs.md:190`.
32
+
33
+ ## Operationalized Execution Checklist
34
+
35
+ ### Gate 0: Scope Lock (must pass before coding)
36
+ - [ ] Decide asset contract for `agents`, `rules`, `CLAUDE.md` (keep vs remove) and update all dependent docs/templates/replacements consistently.
37
+ - [ ] Separate flags: keep `--force` for overwrite behavior only; do not reuse for conflict-skill include.
38
+ - [ ] Decide compatibility policy for legacy flags (`--source-mode`, `--skip-verify`, `--skip-agent-toml`, `--include-hooks`, `--respect-edits`).
39
+
40
+ Dependencies: none.
41
+ Risk: High if skipped (functional regression + broken agent/rules references).
42
+
43
+ ### Phase A: CLI Redesign Skeleton (safe-first)
44
+ - [ ] Add new command surface (`ckc-sync` alias) in `package.json` while retaining `ck-codex-sync`.
45
+ - [ ] Introduce `clean_target.py` and `-f/--fresh` flow.
46
+ - [ ] Wire `register_agents()` call in CLI.
47
+ - [ ] Keep bootstrap signature compatibility until Phase B complete (or ship both changes atomically).
48
+
49
+ Dependencies: Gate 0.
50
+ Risk: Medium (breaking CLI behavior if compatibility policy unclear).
51
+
52
+ ### Phase B: Dependency Bootstrap Symlink
53
+ - [ ] Implement symlink-first venv strategy with fallback creation.
54
+ - [ ] Ensure broken symlink handling + existing real `.venv` short-circuit.
55
+ - [ ] Validate Linux/WSL behavior with dry-run and real run.
56
+
57
+ Dependencies: Phase A (if CLI flags change), or atomic with Phase A for signature safety.
58
+ Risk: Medium (environment-specific symlink behavior).
59
+
60
+ ### Phase C: Registry + Respect-Edits Wiring
61
+ - [ ] Update `sync_assets_from_dir()` to call `maybe_backup()`/`update_entry()`.
62
+ - [ ] Pass `registry` and overwrite policy from CLI callsite explicitly.
63
+ - [ ] Add defensive mkdir in `save_registry()`.
64
+ - [ ] Validate skip/backup/overwrite paths with deterministic fixtures.
65
+
66
+ Dependencies: Gate 0 flag semantics + Phase A CLI args.
67
+ Risk: High (data-loss risk if overwrite path wrong).
68
+
69
+ ### Phase D: Safety + Tests + Docs
70
+ - [ ] Add `tests/test_clean_target.py` (4 tests) and `tests/test_cli_args.py` (6 tests).
71
+ - [ ] Correct expected total test count to 21.
72
+ - [ ] Update `README.md`, `docs/installation-guide.md`, `docs/codebase-summary.md`, `docs/system-architecture.md` to exact final behavior.
73
+ - [ ] Run: `pytest`, `py_compile`, and CLI smoke matrix for both command aliases.
74
+
75
+ Dependencies: Phases A-C complete.
76
+ Risk: Medium (doc/runtime drift if done early).
77
+
78
+ ### Phase E: Release Gate
79
+ - [ ] Confirm no stale references to removed flags/assets.
80
+ - [ ] Confirm backward compatibility decision is documented.
81
+ - [ ] Package/install smoke: `npm install -g .`, `ckc-sync --help`, `ck-codex-sync --help`.
82
+
83
+ Dependencies: Phase D complete.
84
+ Risk: Low.
85
+
86
+ ## Risk Notes (Condensed)
87
+ - **Data safety:** Overwrite logic currently under-specified; treat as release blocker.
88
+ - **Architecture consistency:** Asset contract changes touch constants, templates, docs, runtime behavior; enforce single source of truth.
89
+ - **Breaking CLI UX:** Removing flags without transition policy may break existing automation.
90
+ - **Cross-platform:** Symlink behavior varies; keep fallback path mandatory.
91
+
92
+ ## Unresolved Questions
93
+ 1. Should `rules` and `CLAUDE.md` remain first-class synced assets in v0.2, given `templates/agents-md.md` references?
94
+ 2. Should agent `.md` files be synced to `codex_home/agents` (for conversion) instead of `codex_home/claudekit/agents`, or should conversion read from `claudekit/agents`?
95
+ 3. Should legacy flags remain temporarily as deprecated aliases, or be removed immediately in v0.2?
@@ -0,0 +1,28 @@
1
+ # Completion Report
2
+
3
+ Date: 2026-02-23
4
+ Plan: `plans/260223-1310-v02-cli-redesign-cleanup/`
5
+ Role: project-manager fallback
6
+
7
+ ## What Updated
8
+ - Marked implemented phases and todos complete in:
9
+ - `plans/260223-1310-v02-cli-redesign-cleanup/plan.md`
10
+ - `plans/260223-1310-v02-cli-redesign-cleanup/phase-01-delete-dead-code.md`
11
+ - `plans/260223-1310-v02-cli-redesign-cleanup/phase-02-cli-redesign.md`
12
+ - `plans/260223-1310-v02-cli-redesign-cleanup/phase-03-symlink-venv.md`
13
+ - `plans/260223-1310-v02-cli-redesign-cleanup/phase-04-wire-unused-functions.md`
14
+ - `plans/260223-1310-v02-cli-redesign-cleanup/phase-05-safety-tests-docs.md`
15
+ - Corrected Phase 5 expected test count from 17 to 21.
16
+
17
+ ## Validation Evidence
18
+ - `python3 -m py_compile src/claudekit_codex_sync/*.py` -> pass
19
+ - `PYTHONPATH=src python3 -m pytest tests/ -q` -> `21 passed`
20
+ - Symlink smoke check for Phase 3 (`_try_symlink_venv`) -> source exists, symlink created in temp codex home
21
+
22
+ ## Remaining Pending (Non-implementation Ops)
23
+ - Phase 2: `npm install -g .` (local command registration step)
24
+ - Phase 5: `git commit && git push && npm publish` (release workflow)
25
+
26
+ ## Unresolved Questions
27
+ 1. Do you want plan tracking to treat local install/release commands as required completion gates, or keep them tracked as post-implementation ops?
28
+ 2. Should I also update roadmap/changelog docs to mirror this finalized plan status?
@@ -7,7 +7,8 @@ from pathlib import Path
7
7
  from typing import Dict
8
8
 
9
9
  from .constants import ASSET_DIRS, ASSET_FILES, CONFLICT_SKILLS, EXCLUDED_SKILLS_ALWAYS, MCP_SKILLS
10
- from .utils import is_excluded_path, write_bytes_if_changed
10
+ from .sync_registry import check_user_edit, maybe_backup, update_entry
11
+ from .utils import compute_hash, create_backup, is_excluded_path, write_bytes_if_changed
11
12
 
12
13
 
13
14
  def sync_assets_from_dir(
@@ -16,11 +17,14 @@ def sync_assets_from_dir(
16
17
  codex_home: Path,
17
18
  include_hooks: bool,
18
19
  dry_run: bool,
20
+ registry: dict | None = None,
21
+ force: bool = True,
19
22
  ) -> Dict[str, int]:
20
23
  """Sync non-skill assets from live directory."""
21
24
  claudekit_dir = codex_home / "claudekit"
22
- claudekit_dir.mkdir(parents=True, exist_ok=True)
23
- added = updated = 0
25
+ if not dry_run:
26
+ claudekit_dir.mkdir(parents=True, exist_ok=True)
27
+ added = updated = skipped = 0
24
28
 
25
29
  for dirname in ASSET_DIRS:
26
30
  src_dir = source / dirname
@@ -34,35 +38,81 @@ def sync_assets_from_dir(
34
38
  if not src_file.is_file() or is_excluded_path(src_file.parts):
35
39
  continue
36
40
  rel = src_file.relative_to(src_dir)
41
+ rel_path = f"claudekit/{dirname}/{rel}"
37
42
  dst = dst_dir / rel
43
+
44
+ if not force and registry and dst.exists():
45
+ entry = registry.get("entries", {}).get(rel_path)
46
+ if entry:
47
+ if dry_run and check_user_edit(entry, dst):
48
+ skipped += 1
49
+ print(f"skip(user-edit): {rel_path}")
50
+ continue
51
+ backup = maybe_backup(registry, rel_path, dst, respect_edits=True)
52
+ if backup:
53
+ skipped += 1
54
+ print(f"skip(user-edit): {rel_path}")
55
+ continue
56
+ elif compute_hash(src_file) != compute_hash(dst):
57
+ if dry_run:
58
+ print(f"[dry-run] backup(untracked): {rel_path}")
59
+ else:
60
+ backup = create_backup(dst)
61
+ print(f"backup(untracked): {rel_path} -> {backup.name}")
62
+
38
63
  data = src_file.read_bytes()
39
64
  mode = src_file.stat().st_mode & 0o777 if src_file.stat().st_mode & 0o111 else None
40
65
  changed, is_added = write_bytes_if_changed(dst, data, mode=mode, dry_run=dry_run)
41
66
  if changed:
42
67
  if is_added:
43
68
  added += 1
44
- print(f"add: claudekit/{dirname}/{rel}")
69
+ print(f"add: {rel_path}")
45
70
  else:
46
71
  updated += 1
47
- print(f"update: claudekit/{dirname}/{rel}")
72
+ print(f"update: {rel_path}")
73
+ if registry and not dry_run and dst.exists():
74
+ update_entry(registry, rel_path, src_file, dst)
48
75
 
49
76
  for filename in ASSET_FILES:
50
77
  src = source / filename
51
78
  if not src.exists():
52
79
  continue
80
+ rel_path = f"claudekit/{filename}"
53
81
  dst = claudekit_dir / filename
82
+
83
+ if not force and registry and dst.exists():
84
+ entry = registry.get("entries", {}).get(rel_path)
85
+ if entry:
86
+ if dry_run and check_user_edit(entry, dst):
87
+ skipped += 1
88
+ print(f"skip(user-edit): {rel_path}")
89
+ continue
90
+ backup = maybe_backup(registry, rel_path, dst, respect_edits=True)
91
+ if backup:
92
+ skipped += 1
93
+ print(f"skip(user-edit): {rel_path}")
94
+ continue
95
+ elif compute_hash(src) != compute_hash(dst):
96
+ if dry_run:
97
+ print(f"[dry-run] backup(untracked): {rel_path}")
98
+ else:
99
+ backup = create_backup(dst)
100
+ print(f"backup(untracked): {rel_path} -> {backup.name}")
101
+
54
102
  data = src.read_bytes()
55
103
  mode = src.stat().st_mode & 0o777 if src.stat().st_mode & 0o111 else None
56
104
  changed, is_added = write_bytes_if_changed(dst, data, mode=mode, dry_run=dry_run)
57
105
  if changed:
58
106
  if is_added:
59
107
  added += 1
60
- print(f"add: claudekit/{filename}")
108
+ print(f"add: {rel_path}")
61
109
  else:
62
110
  updated += 1
63
- print(f"update: claudekit/{filename}")
111
+ print(f"update: {rel_path}")
112
+ if registry and not dry_run and dst.exists():
113
+ update_entry(registry, rel_path, src, dst)
64
114
 
65
- return {"added": added, "updated": updated, "removed": 0, "managed_files": added + updated}
115
+ return {"added": added, "updated": updated, "removed": 0, "skipped": skipped}
66
116
 
67
117
 
68
118
  def sync_skills_from_dir(
@@ -120,6 +170,7 @@ def sync_skills_from_dir(
120
170
  ignore = shutil.ignore_patterns("*.pyc", "__pycache__", ".venv", "node_modules", "dist", "build")
121
171
  shutil.copytree(skill_dir, dst, ignore=ignore)
122
172
 
123
- skills_dst.mkdir(parents=True, exist_ok=True)
173
+ if not dry_run:
174
+ skills_dst.mkdir(parents=True, exist_ok=True)
124
175
  total_skills = len(list(skills_dst.rglob("SKILL.md")))
125
176
  return {"added": added, "updated": updated, "skipped": skipped, "total_skills": total_skills}
@@ -16,7 +16,16 @@ from .constants import (
16
16
  MCP_SKILLS,
17
17
  )
18
18
  from .source_resolver import collect_skill_entries, zip_mode
19
- from .utils import load_manifest, save_manifest, write_bytes_if_changed
19
+ from .utils import SyncError, load_manifest, save_manifest, write_bytes_if_changed
20
+
21
+
22
+ def _validate_zip_relpath(rel: str, zip_name: str) -> str:
23
+ """Validate zip relative path and return normalized form."""
24
+ normalized = rel.replace("\\", "/")
25
+ path = Path(normalized)
26
+ if path.is_absolute() or ".." in path.parts:
27
+ raise SyncError(f"Unsafe zip entry path: {zip_name}")
28
+ return normalized
20
29
 
21
30
 
22
31
  def sync_assets(
@@ -35,7 +44,7 @@ def sync_assets(
35
44
  for name in zf.namelist():
36
45
  if name.endswith("/") or not name.startswith(".claude/"):
37
46
  continue
38
- rel = name[len(".claude/") :]
47
+ rel = _validate_zip_relpath(name[len(".claude/") :], name)
39
48
  first = rel.split("/", 1)[0]
40
49
  if first == "hooks" and include_hooks:
41
50
  selected.append((name, rel))
@@ -47,10 +56,11 @@ def sync_assets(
47
56
  added = updated = removed = 0
48
57
 
49
58
  for rel in sorted(old_manifest - new_manifest):
50
- target = claudekit_dir / rel
59
+ safe_rel = _validate_zip_relpath(rel, rel)
60
+ target = claudekit_dir / safe_rel
51
61
  if target.exists():
52
62
  removed += 1
53
- print(f"remove: {rel}")
63
+ print(f"remove: {safe_rel}")
54
64
  if not dry_run:
55
65
  target.unlink()
56
66
 
@@ -135,6 +145,7 @@ def sync_skills(
135
145
  dst = dst_skill_dir / inner
136
146
  write_bytes_if_changed(dst, data, mode=zip_mode(info), dry_run=False)
137
147
 
138
- skills_dir.mkdir(parents=True, exist_ok=True)
148
+ if not dry_run:
149
+ skills_dir.mkdir(parents=True, exist_ok=True)
139
150
  total_skills = len(list(skills_dir.rglob("SKILL.md")))
140
151
  return {"added": added, "updated": updated, "skipped": skipped, "total_skills": total_skills}
@@ -0,0 +1,59 @@
1
+ """Clean target directories for --fresh sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+
9
+ def clean_target(codex_home: Path, *, dry_run: bool) -> int:
10
+ """Remove agents, skills (keep .venv), prompts, claudekit before fresh sync."""
11
+ removed = 0
12
+
13
+ for subdir in ("agents", "prompts", "claudekit"):
14
+ target = codex_home / subdir
15
+ if target.exists():
16
+ count = sum(1 for item in target.rglob("*") if item.is_file())
17
+ removed += count
18
+ print(f"fresh: rm {subdir}/ ({count} files)")
19
+ if not dry_run:
20
+ shutil.rmtree(target)
21
+
22
+ skills = codex_home / "skills"
23
+ if skills.exists():
24
+ for item in skills.iterdir():
25
+ if item.name == ".venv":
26
+ # Keep symlinks (pointing to ~/.claude/skills/.venv)
27
+ # Delete real venv dirs so symlink can be created on next bootstrap
28
+ if item.is_symlink():
29
+ continue
30
+ # Real venv dir → delete so symlink-first strategy works
31
+ count = sum(1 for p in item.rglob("*") if p.is_file())
32
+ removed += count
33
+ if not dry_run:
34
+ shutil.rmtree(item)
35
+ print("fresh: rm skills/.venv (real dir, will be re-symlinked)")
36
+ continue
37
+ if item.is_dir():
38
+ count = sum(1 for path in item.rglob("*") if path.is_file())
39
+ removed += count
40
+ if not dry_run:
41
+ shutil.rmtree(item)
42
+ else:
43
+ removed += 1
44
+ if not dry_run:
45
+ item.unlink()
46
+ print("fresh: rm skills/* (kept .venv)")
47
+
48
+ for name in (
49
+ ".claudekit-sync-registry.json",
50
+ ".sync-manifest-assets.txt",
51
+ ".claudekit-generated-prompts.txt",
52
+ ):
53
+ target = codex_home / name
54
+ if target.exists():
55
+ removed += 1
56
+ if not dry_run:
57
+ target.unlink()
58
+
59
+ return removed