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
@@ -8,12 +8,18 @@ import json
8
8
  import os
9
9
  import zipfile
10
10
  from pathlib import Path
11
- from typing import Any, Dict, Optional
11
+ from typing import Any, Dict
12
12
 
13
13
  from .asset_sync_dir import sync_assets_from_dir, sync_skills_from_dir
14
14
  from .asset_sync_zip import sync_assets, sync_skills
15
15
  from .bridge_generator import ensure_bridge_skill
16
- from .config_enforcer import ensure_agents, enforce_config, enforce_multi_agent_flag
16
+ from .clean_target import clean_target
17
+ from .config_enforcer import (
18
+ enforce_config,
19
+ enforce_multi_agent_flag,
20
+ ensure_agents,
21
+ register_agents,
22
+ )
17
23
  from .dep_bootstrapper import bootstrap_deps
18
24
  from .path_normalizer import normalize_agent_tomls, normalize_files
19
25
  from .prompt_exporter import export_prompts
@@ -26,41 +32,85 @@ from .utils import SyncError, eprint
26
32
  def print_summary(summary: Dict[str, Any]) -> None:
27
33
  print(json.dumps(summary, indent=2, ensure_ascii=False))
28
34
 
35
+
29
36
  def parse_args() -> argparse.Namespace:
30
37
  p = argparse.ArgumentParser(
31
- description="All-in-one ClaudeKit -> Codex sync script."
38
+ prog="ckc-sync",
39
+ description="Sync ClaudeKit skills, agents, and config to Codex CLI.",
40
+ )
41
+ p.add_argument(
42
+ "-g",
43
+ "--global",
44
+ dest="global_scope",
45
+ action="store_true",
46
+ help="Sync to ~/.codex/ (default: ./.codex/)",
47
+ )
48
+ p.add_argument(
49
+ "-f",
50
+ "--fresh",
51
+ action="store_true",
52
+ help="Clean target dirs before sync",
53
+ )
54
+ p.add_argument(
55
+ "--force",
56
+ action="store_true",
57
+ help="Overwrite user-edited files without backup (required for zip write mode)",
58
+ )
59
+ p.add_argument(
60
+ "--zip",
61
+ dest="zip_path",
62
+ type=Path,
63
+ help="Sync from zip instead of live ~/.claude/",
64
+ )
65
+ p.add_argument(
66
+ "--source",
67
+ type=Path,
68
+ default=None,
69
+ help="Custom source dir (default: ~/.claude/)",
70
+ )
71
+ p.add_argument(
72
+ "--mcp",
73
+ action="store_true",
74
+ help="Include MCP skills",
75
+ )
76
+ p.add_argument(
77
+ "--no-deps",
78
+ action="store_true",
79
+ help="Skip dependency bootstrap (venv)",
80
+ )
81
+ p.add_argument(
82
+ "-n",
83
+ "--dry-run",
84
+ action="store_true",
85
+ help="Preview only",
32
86
  )
33
- p.add_argument("--zip", dest="zip_path", type=Path, help="Specific ClaudeKit zip path")
34
- p.add_argument("--codex-home", type=Path, default=None, help="Codex home (default: $CODEX_HOME or ~/.codex)")
35
- p.add_argument("--workspace", type=Path, default=Path.cwd(), help="Workspace root for AGENTS.md")
36
- p.add_argument("--source-mode", choices=["auto", "live", "zip"], default="auto", help="Source mode")
37
- p.add_argument("--source-dir", type=Path, default=None, help="Source directory for live mode")
38
- p.add_argument("--include-mcp", action="store_true", help="Include MCP skills/prompts")
39
- p.add_argument("--include-hooks", action="store_true", help="Include hooks")
40
- p.add_argument("--include-conflicts", action="store_true", help="Include conflicting skills")
41
- p.add_argument("--include-test-deps", action="store_true", help="Install test requirements")
42
- p.add_argument("--skip-bootstrap", action="store_true", help="Skip dependency bootstrap")
43
- p.add_argument("--skip-verify", action="store_true", help="Skip verification")
44
- p.add_argument("--skip-agent-toml", action="store_true", help="Skip agent TOML normalization")
45
- p.add_argument("--respect-edits", action="store_true", help="Backup user-edited files")
46
- p.add_argument("--dry-run", action="store_true", help="Preview changes only")
47
87
  return p.parse_args()
48
88
 
49
89
 
50
90
  def main() -> int:
51
91
  args = parse_args()
52
- codex_home = (args.codex_home or Path(os.environ.get("CODEX_HOME", "~/.codex"))).expanduser().resolve()
53
- workspace = args.workspace.expanduser().resolve()
54
- workspace.mkdir(parents=True, exist_ok=True)
55
92
 
56
- # Load registry for backup/respect-edits
93
+ if args.global_scope:
94
+ codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser().resolve()
95
+ else:
96
+ codex_home = (Path.cwd() / ".codex").resolve()
97
+
98
+ workspace = Path.cwd().resolve()
99
+ if not args.dry_run:
100
+ codex_home.mkdir(parents=True, exist_ok=True)
101
+
102
+ if args.fresh:
103
+ removed = clean_target(codex_home, dry_run=args.dry_run)
104
+ print(f"fresh: removed {removed} files")
105
+
57
106
  registry = load_registry(codex_home)
58
107
 
59
- source_mode = args.source_mode
60
- use_live = source_mode == "live" or (source_mode == "auto" and args.source_dir)
108
+ use_live = args.zip_path is None
109
+ if not use_live and not args.force and not args.dry_run:
110
+ raise SyncError("zip sync requires --force for write mode")
61
111
 
62
112
  if use_live:
63
- source = args.source_dir or detect_claude_source()
113
+ source = args.source or detect_claude_source()
64
114
  validation = validate_source(source)
65
115
  print(f"source: {source} (live)")
66
116
  print(f"validation: {validation}")
@@ -71,101 +121,85 @@ def main() -> int:
71
121
  registry["sourceDir"] = None
72
122
 
73
123
  print(f"codex_home: {codex_home}")
74
- print(f"workspace: {workspace}")
75
- print(
76
- f"include_mcp={args.include_mcp} include_hooks={args.include_hooks} "
77
- f"dry_run={args.dry_run} respect_edits={args.respect_edits}"
78
- )
79
-
80
- codex_home.mkdir(parents=True, exist_ok=True)
124
+ print(f"scope: {'global' if args.global_scope else 'project'}")
125
+ print(f"fresh={args.fresh} force={args.force} mcp={args.mcp} dry_run={args.dry_run}")
81
126
 
82
127
  if use_live:
83
128
  assets_stats = sync_assets_from_dir(
84
- source, codex_home=codex_home, include_hooks=args.include_hooks, dry_run=args.dry_run
129
+ source,
130
+ codex_home=codex_home,
131
+ include_hooks=True,
132
+ dry_run=args.dry_run,
133
+ registry=registry,
134
+ force=args.force,
85
135
  )
86
136
  skills_stats = sync_skills_from_dir(
87
137
  source,
88
138
  codex_home=codex_home,
89
- include_mcp=args.include_mcp,
90
- include_conflicts=args.include_conflicts,
139
+ include_mcp=args.mcp,
140
+ include_conflicts=False,
91
141
  dry_run=args.dry_run,
92
142
  )
93
143
  else:
94
- with zipfile.ZipFile(zip_path) as zf: # type: ignore
144
+ with zipfile.ZipFile(zip_path) as zf:
95
145
  assets_stats = sync_assets(
96
- zf, codex_home=codex_home, include_hooks=args.include_hooks, dry_run=args.dry_run
146
+ zf,
147
+ codex_home=codex_home,
148
+ include_hooks=True,
149
+ dry_run=args.dry_run,
97
150
  )
98
151
  skills_stats = sync_skills(
99
152
  zf,
100
153
  codex_home=codex_home,
101
- include_mcp=args.include_mcp,
102
- include_conflicts=args.include_conflicts,
154
+ include_mcp=args.mcp,
155
+ include_conflicts=False,
103
156
  dry_run=args.dry_run,
104
157
  )
105
158
 
106
- print(
107
- f"assets: added={assets_stats['added']} updated={assets_stats['updated']} "
108
- f"removed={assets_stats['removed']}"
109
- )
110
- print(
111
- f"skills: added={skills_stats['added']} updated={skills_stats['updated']} "
112
- f"skipped={skills_stats['skipped']}"
113
- )
159
+ print(f"assets: added={assets_stats['added']} updated={assets_stats['updated']}")
160
+ print(f"skills: added={skills_stats['added']} updated={skills_stats['updated']}")
114
161
 
115
- changed = normalize_files(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run)
162
+ changed = normalize_files(codex_home=codex_home, include_mcp=args.mcp, dry_run=args.dry_run)
116
163
  print(f"normalize_changed={changed}")
117
164
 
118
- # Agent TOML normalization
119
- agent_toml_changed = 0
120
- if not args.skip_agent_toml:
121
- agent_toml_changed = normalize_agent_tomls(codex_home=codex_home, dry_run=args.dry_run)
122
- print(f"agent_toml_changed={agent_toml_changed}")
165
+ agent_toml_changed = normalize_agent_tomls(codex_home=codex_home, dry_run=args.dry_run)
166
+ print(f"agent_toml_changed={agent_toml_changed}")
167
+
168
+ agents_registered = register_agents(codex_home=codex_home, dry_run=args.dry_run)
169
+ print(f"agents_registered={agents_registered}")
123
170
 
124
171
  baseline_changed = 0
125
172
  if ensure_agents(workspace=workspace, dry_run=args.dry_run):
126
173
  baseline_changed += 1
127
- print(f"upsert: {workspace / 'AGENTS.md'}")
128
- if enforce_config(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run):
174
+ if enforce_config(codex_home=codex_home, include_mcp=args.mcp, dry_run=args.dry_run):
129
175
  baseline_changed += 1
130
- print(f"upsert: {codex_home / 'config.toml'}")
131
176
  if ensure_bridge_skill(codex_home=codex_home, dry_run=args.dry_run):
132
177
  baseline_changed += 1
133
- print(f"upsert: {codex_home / 'skills' / 'claudekit-command-bridge'}")
134
178
 
135
- # Enable multi_agent flag
136
179
  config_path = codex_home / "config.toml"
137
- if enforce_multi_agent_flag(config_path, dry_run=args.dry_run):
138
- print(f"upsert: multi_agent = true in {config_path}")
139
-
180
+ enforce_multi_agent_flag(config_path, dry_run=args.dry_run)
140
181
  print(f"baseline_changed={baseline_changed}")
141
182
 
142
- prompt_stats = export_prompts(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run)
143
- print(
144
- f"prompts: added={prompt_stats['added']} updated={prompt_stats['updated']} "
145
- f"total_generated={prompt_stats['total_generated']}"
146
- )
183
+ prompt_stats = export_prompts(codex_home=codex_home, include_mcp=args.mcp, dry_run=args.dry_run)
184
+ print(f"prompts: added={prompt_stats['added']} total={prompt_stats['total_generated']}")
147
185
 
148
186
  bootstrap_stats = None
149
- if not args.skip_bootstrap:
187
+ if not args.no_deps:
150
188
  bootstrap_stats = bootstrap_deps(
151
189
  codex_home=codex_home,
152
- include_mcp=args.include_mcp,
153
- include_test_deps=args.include_test_deps,
190
+ include_mcp=args.mcp,
154
191
  dry_run=args.dry_run,
155
192
  )
156
193
  print(
157
- f"bootstrap: python_ok={bootstrap_stats['python_ok']} "
158
- f"python_fail={bootstrap_stats['python_fail']}"
194
+ f"bootstrap: py_ok={bootstrap_stats['python_ok']} "
195
+ f"py_fail={bootstrap_stats['python_fail']}"
159
196
  )
160
197
  if (bootstrap_stats["python_fail"] or bootstrap_stats["node_fail"]) and not args.dry_run:
161
198
  raise SyncError("Dependency bootstrap reported failures")
162
199
 
163
- verify_stats = None
164
- if not args.skip_verify:
165
- verify_stats = verify_runtime(codex_home=codex_home, dry_run=args.dry_run)
166
- print(f"verify: {verify_stats}")
200
+ verify_stats = verify_runtime(codex_home=codex_home, dry_run=args.dry_run)
201
+ print(f"verify: {verify_stats}")
167
202
 
168
- # Save registry
169
203
  if not args.dry_run:
170
204
  save_registry(codex_home, registry)
171
205
 
@@ -173,21 +207,19 @@ def main() -> int:
173
207
  "source": str(source) if use_live else str(zip_path),
174
208
  "source_mode": "live" if use_live else "zip",
175
209
  "codex_home": str(codex_home),
176
- "workspace": str(workspace),
177
- "dry_run": args.dry_run,
178
- "include_mcp": args.include_mcp,
179
- "include_hooks": args.include_hooks,
210
+ "scope": "global" if args.global_scope else "project",
211
+ "fresh": args.fresh,
180
212
  "assets": assets_stats,
181
213
  "skills": skills_stats,
182
214
  "normalize_changed": changed,
183
215
  "agent_toml_changed": agent_toml_changed,
184
- "baseline_changed": baseline_changed,
216
+ "agents_registered": agents_registered,
185
217
  "prompts": prompt_stats,
186
218
  "bootstrap": bootstrap_stats,
187
219
  "verify": verify_stats,
188
220
  }
189
221
  print_summary(summary)
190
- print("done: claudekit all-in-one sync completed")
222
+ print("done: ckc-sync completed")
191
223
  return 0
192
224
 
193
225
 
@@ -2,19 +2,8 @@
2
2
 
3
3
  from typing import List, Set, Tuple
4
4
 
5
- ASSET_DIRS = {"agents", "commands", "output-styles", "rules", "scripts"}
6
- ASSET_FILES = {
7
- "CLAUDE.md",
8
- ".ck.json",
9
- ".ckignore",
10
- ".env.example",
11
- ".mcp.json.example",
12
- "settings.json",
13
- "metadata.json",
14
- "statusline.cjs",
15
- "statusline.sh",
16
- "statusline.ps1",
17
- }
5
+ ASSET_DIRS = {"commands", "output-styles", "scripts"}
6
+ ASSET_FILES = {".env.example"}
18
7
  ASSET_MANIFEST = ".sync-manifest-assets.txt"
19
8
  PROMPT_MANIFEST = ".claudekit-generated-prompts.txt"
20
9
  REGISTRY_FILE = ".claudekit-sync-registry.json"
@@ -10,60 +10,106 @@ from typing import Dict
10
10
  from .utils import eprint, is_excluded_path, run_cmd
11
11
 
12
12
 
13
+ def _try_symlink_venv(codex_home: Path, *, dry_run: bool) -> bool:
14
+ """Try to symlink from existing ClaudeKit venv. Returns True if successful."""
15
+ source_venv = Path.home() / ".claude" / "skills" / ".venv"
16
+ target_venv = codex_home / "skills" / ".venv"
17
+
18
+ if target_venv.is_symlink() and not target_venv.resolve().exists():
19
+ if not dry_run:
20
+ target_venv.unlink()
21
+
22
+ if target_venv.is_symlink() and target_venv.resolve().exists():
23
+ print("skip: skills/.venv (symlink intact)")
24
+ return True
25
+ if target_venv.exists() and not target_venv.is_symlink():
26
+ print("skip: skills/.venv (exists)")
27
+ return True
28
+
29
+ if source_venv.exists():
30
+ if not dry_run:
31
+ target_venv.parent.mkdir(parents=True, exist_ok=True)
32
+ if target_venv.is_symlink():
33
+ target_venv.unlink()
34
+ target_venv.symlink_to(source_venv)
35
+ print(f"symlink: skills/.venv -> {source_venv}")
36
+ return True
37
+
38
+ return False
39
+
40
+
41
+ def _install_node_deps(*, skills_dir: Path, include_mcp: bool, dry_run: bool) -> tuple[int, int]:
42
+ """Install Node dependencies for skills."""
43
+ node_ok = node_fail = 0
44
+ npm = shutil.which("npm")
45
+ if not npm:
46
+ eprint("npm not found; skipping Node dependency bootstrap")
47
+ return node_ok, node_fail
48
+
49
+ pkg_files = sorted(skills_dir.rglob("package.json"))
50
+ for pkg in pkg_files:
51
+ if is_excluded_path(pkg.parts):
52
+ continue
53
+ if not include_mcp and ("mcp-builder" in pkg.parts or "mcp-management" in pkg.parts):
54
+ continue
55
+ try:
56
+ run_cmd([npm, "install", "--prefix", str(pkg.parent)], dry_run=dry_run)
57
+ node_ok += 1
58
+ except subprocess.CalledProcessError:
59
+ node_fail += 1
60
+ eprint(f"node deps failed: {pkg.parent}")
61
+ return node_ok, node_fail
62
+
63
+
13
64
  def bootstrap_deps(
14
65
  *,
15
66
  codex_home: Path,
16
67
  include_mcp: bool,
17
- include_test_deps: bool,
18
68
  dry_run: bool,
19
69
  ) -> Dict[str, int]:
20
70
  """Bootstrap Python and Node dependencies for skills."""
21
71
  skills_dir = codex_home / "skills"
22
- venv_dir = skills_dir / ".venv"
23
-
24
- if not shutil.which("python3"):
25
- from .utils import SyncError
26
- raise SyncError("python3 not found")
27
-
28
72
  py_ok = py_fail = node_ok = node_fail = 0
29
73
 
30
- run_cmd(["python3", "-m", "venv", str(venv_dir)], dry_run=dry_run)
74
+ symlinked = _try_symlink_venv(codex_home, dry_run=dry_run)
75
+ venv_dir = skills_dir / ".venv"
31
76
  py_bin = venv_dir / "bin" / "python3"
32
- run_cmd([str(py_bin), "-m", "pip", "install", "--upgrade", "pip"], dry_run=dry_run)
77
+ if symlinked and not dry_run and not py_bin.exists():
78
+ eprint("warn: skills/.venv missing bin/python3, recreating local venv")
79
+ if venv_dir.is_symlink():
80
+ venv_dir.unlink()
81
+ symlinked = False
33
82
 
34
- req_files = sorted(skills_dir.rglob("requirements*.txt"))
35
- for req in req_files:
36
- rel = req.relative_to(skills_dir).as_posix()
37
- if is_excluded_path(req.parts):
38
- continue
39
- if not include_test_deps and "/test" in rel:
40
- continue
41
- if not include_mcp and ("mcp-builder" in req.parts or "mcp-management" in req.parts):
42
- continue
43
- try:
44
- run_cmd([str(py_bin), "-m", "pip", "install", "-r", str(req)], dry_run=dry_run)
45
- py_ok += 1
46
- except subprocess.CalledProcessError:
47
- py_fail += 1
48
- eprint(f"python deps failed: {req}")
83
+ if not symlinked:
84
+ if not shutil.which("python3"):
85
+ from .utils import SyncError
49
86
 
50
- npm = shutil.which("npm")
51
- if npm:
52
- pkg_files = sorted(skills_dir.rglob("package.json"))
53
- for pkg in pkg_files:
54
- rel = pkg.relative_to(skills_dir).as_posix()
55
- if is_excluded_path(pkg.parts):
87
+ raise SyncError("python3 not found")
88
+ run_cmd(["python3", "-m", "venv", str(venv_dir)], dry_run=dry_run)
89
+ run_cmd([str(py_bin), "-m", "pip", "install", "--upgrade", "pip"], dry_run=dry_run)
90
+
91
+ # Skip dependency install when venv is symlinked — packages already in source
92
+ if not symlinked:
93
+ req_files = sorted(skills_dir.rglob("requirements*.txt"))
94
+ for req in req_files:
95
+ if is_excluded_path(req.parts):
56
96
  continue
57
- if not include_mcp and ("mcp-builder" in pkg.parts or "mcp-management" in pkg.parts):
97
+ if not include_mcp and ("mcp-builder" in req.parts or "mcp-management" in req.parts):
58
98
  continue
59
99
  try:
60
- run_cmd([npm, "install", "--prefix", str(pkg.parent)], dry_run=dry_run)
61
- node_ok += 1
100
+ run_cmd([str(py_bin), "-m", "pip", "install", "-r", str(req)], dry_run=dry_run)
101
+ py_ok += 1
62
102
  except subprocess.CalledProcessError:
63
- node_fail += 1
64
- eprint(f"node deps failed: {pkg.parent}")
103
+ py_fail += 1
104
+ eprint(f"python deps failed: {req}")
105
+
106
+ node_ok, node_fail = _install_node_deps(
107
+ skills_dir=skills_dir,
108
+ include_mcp=include_mcp,
109
+ dry_run=dry_run,
110
+ )
65
111
  else:
66
- eprint("npm not found; skipping Node dependency bootstrap")
112
+ print("skip: deps install (venv symlinked, packages shared)")
67
113
 
68
114
  return {
69
115
  "python_ok": py_ok,
@@ -83,6 +83,8 @@ def convert_agents_md_to_toml(*, codex_home: Path, dry_run: bool) -> int:
83
83
  agents_dir = codex_home / "agents"
84
84
  if not agents_dir.exists():
85
85
  return 0
86
+ if not dry_run:
87
+ agents_dir.mkdir(parents=True, exist_ok=True)
86
88
 
87
89
  converted = 0
88
90
  for md_file in sorted(agents_dir.glob("*.md")):
@@ -2,10 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import shutil
6
+ import subprocess
5
7
  from pathlib import Path
6
- from typing import Dict, Optional, Any
7
-
8
- from .utils import run_cmd
8
+ from typing import Any, Dict
9
9
 
10
10
 
11
11
  def verify_runtime(*, codex_home: Path, dry_run: bool) -> Dict[str, Any]:
@@ -13,19 +13,29 @@ def verify_runtime(*, codex_home: Path, dry_run: bool) -> Dict[str, Any]:
13
13
  if dry_run:
14
14
  return {"skipped": True}
15
15
 
16
- run_cmd(["codex", "--help"], dry_run=False)
16
+ # Silent codex check — just verify it exists and runs
17
+ codex_bin = shutil.which("codex")
18
+ codex_ok = False
19
+ if codex_bin:
20
+ result = subprocess.run(
21
+ [codex_bin, "--version"], capture_output=True, timeout=10
22
+ )
23
+ codex_ok = result.returncode == 0
17
24
 
25
+ # Silent copywriting check
18
26
  copy_script = codex_home / "skills" / "copywriting" / "scripts" / "extract-writing-styles.py"
19
27
  py_bin = codex_home / "skills" / ".venv" / "bin" / "python3"
20
28
  copywriting_ok = False
21
29
  if copy_script.exists() and py_bin.exists():
22
- run_cmd([str(py_bin), str(copy_script), "--list"], dry_run=False)
23
- copywriting_ok = True
30
+ result = subprocess.run(
31
+ [str(py_bin), str(copy_script), "--list"], capture_output=True, timeout=30
32
+ )
33
+ copywriting_ok = result.returncode == 0
24
34
 
25
35
  prompts_count = len(list((codex_home / "prompts").glob("*.md")))
26
36
  skills_count = len(list((codex_home / "skills").rglob("SKILL.md")))
27
37
  return {
28
- "codex_help": "ok",
38
+ "codex": "ok" if codex_ok else "missing",
29
39
  "copywriting": "ok" if copywriting_ok else "skipped",
30
40
  "prompts": prompts_count,
31
41
  "skills": skills_count,
@@ -41,7 +41,7 @@ def detect_claude_source() -> Path:
41
41
  for p in candidates:
42
42
  if p.exists() and (p / "skills").is_dir():
43
43
  return p
44
- raise SyncError("Claude Code not found. Use --source-dir to specify.")
44
+ raise SyncError("Claude Code not found. Use --source to specify.")
45
45
 
46
46
 
47
47
  def validate_source(source: Path) -> Dict[str, bool]:
@@ -61,11 +61,19 @@ def collect_skill_entries(zf: zipfile.ZipFile) -> Dict[str, List[Tuple[str, str]
61
61
  for name in zf.namelist():
62
62
  if name.endswith("/") or not name.startswith(".claude/skills/"):
63
63
  continue
64
- rel = name[len(".claude/skills/") :]
64
+ rel = name[len(".claude/skills/") :].replace("\\", "/")
65
+ path = Path(rel)
66
+ if path.is_absolute() or ".." in path.parts:
67
+ raise SyncError(f"Unsafe zip entry path: {name}")
65
68
  parts = rel.split("/", 1)
66
69
  if len(parts) != 2:
67
70
  continue
68
71
  skill, inner = parts
72
+ inner_path = Path(inner)
73
+ if Path(skill).is_absolute() or ".." in Path(skill).parts:
74
+ raise SyncError(f"Unsafe skill name in zip entry: {name}")
75
+ if inner_path.is_absolute() or ".." in inner_path.parts:
76
+ raise SyncError(f"Unsafe skill file path in zip entry: {name}")
69
77
  skill_files.setdefault(skill, []).append((name, inner))
70
78
  return skill_files
71
79
 
@@ -28,6 +28,7 @@ def load_registry(codex_home: Path) -> Dict[str, Any]:
28
28
  def save_registry(codex_home: Path, registry: Dict[str, Any]) -> None:
29
29
  """Save sync registry to disk."""
30
30
  registry_path = codex_home / REGISTRY_FILE
31
+ registry_path.parent.mkdir(parents=True, exist_ok=True)
31
32
  registry["lastSync"] = datetime.now(timezone.utc).isoformat()
32
33
  registry_path.write_text(json.dumps(registry, indent=2), encoding="utf-8")
33
34
 
@@ -35,11 +35,8 @@ Codex working profile for this workspace, adapted from ClaudeKit rules and workf
35
35
  - Activate relevant skills intentionally per task.
36
36
  - For legacy ClaudeKit command intents (`/ck-help`, `/coding-level`, `/ask`, `/docs/*`, `/journal`, `/watzup`), use `$claudekit-command-bridge`.
37
37
 
38
- ## Reference Material (Imported from ClaudeKit)
39
-
40
- - `~/.codex/claudekit/CLAUDE.md`
41
- - `~/.codex/claudekit/rules/development-rules.md`
42
- - `~/.codex/claudekit/rules/primary-workflow.md`
43
- - `~/.codex/claudekit/rules/orchestration-protocol.md`
44
- - `~/.codex/claudekit/rules/documentation-management.md`
45
- - `~/.codex/claudekit/rules/team-coordination-rules.md`
38
+ ## Reference Material
39
+
40
+ - `README.md`
41
+ - `docs/`
42
+ - `plans/`
@@ -0,0 +1,75 @@
1
+ """Tests for clean_target module."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from claudekit_codex_sync.clean_target import clean_target
7
+
8
+
9
+ def test_clean_removes_agents(tmp_path: Path):
10
+ """Clean removes agents dir."""
11
+ agents = tmp_path / "agents"
12
+ agents.mkdir()
13
+ (agents / "planner.toml").write_text("model = 'test'")
14
+ (agents / "researcher.toml").write_text("model = 'test'")
15
+
16
+ removed = clean_target(tmp_path, dry_run=False)
17
+ assert not agents.exists()
18
+ assert removed >= 2
19
+
20
+
21
+ def test_clean_keeps_venv_symlink(tmp_path: Path):
22
+ """Clean keeps symlinked .venv but deletes real .venv dirs."""
23
+ skills = tmp_path / "skills"
24
+ skills.mkdir()
25
+
26
+ # Create a real source venv to symlink to
27
+ source_venv = tmp_path / "source_venv"
28
+ source_venv.mkdir()
29
+ (source_venv / "bin").mkdir()
30
+ (source_venv / "bin" / "python3").write_text("#!/usr/bin/env python3")
31
+
32
+ # Symlink .venv → source_venv (simulates symlink to ~/.claude/skills/.venv)
33
+ venv = skills / ".venv"
34
+ venv.symlink_to(source_venv)
35
+
36
+ skill = skills / "my-skill"
37
+ skill.mkdir()
38
+ (skill / "SKILL.md").write_text("# test")
39
+
40
+ clean_target(tmp_path, dry_run=False)
41
+ assert venv.is_symlink(), "symlinked .venv should survive cleaning"
42
+ assert not skill.exists(), "skill dirs should be removed"
43
+
44
+
45
+ def test_clean_deletes_real_venv(tmp_path: Path):
46
+ """Clean deletes real (non-symlink) .venv for re-symlinking."""
47
+ skills = tmp_path / "skills"
48
+ skills.mkdir()
49
+ venv = skills / ".venv"
50
+ venv.mkdir()
51
+ (venv / "bin").mkdir()
52
+ (venv / "bin" / "python3").write_text("#!/usr/bin/env python3")
53
+
54
+ clean_target(tmp_path, dry_run=False)
55
+ assert not venv.exists(), "real .venv should be deleted for re-symlinking"
56
+
57
+
58
+ def test_clean_dry_run(tmp_path: Path):
59
+ """Dry run counts but doesn't delete."""
60
+ agents = tmp_path / "agents"
61
+ agents.mkdir()
62
+ (agents / "test.toml").write_text("x = 1")
63
+
64
+ removed = clean_target(tmp_path, dry_run=True)
65
+ assert removed >= 1
66
+ assert agents.exists(), "dry-run should not delete"
67
+
68
+
69
+ def test_clean_removes_registry(tmp_path: Path):
70
+ """Clean clears sync registry."""
71
+ registry = tmp_path / ".claudekit-sync-registry.json"
72
+ registry.write_text(json.dumps({"version": 1}))
73
+
74
+ clean_target(tmp_path, dry_run=False)
75
+ assert not registry.exists()