claudekit-codex-sync 0.1.0 → 0.2.0

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 (33) hide show
  1. package/README.md +76 -59
  2. package/docs/codebase-summary.md +40 -43
  3. package/docs/codex-vs-claude-agents.md +53 -42
  4. package/docs/installation-guide.md +67 -23
  5. package/docs/project-overview-pdr.md +20 -26
  6. package/docs/project-roadmap.md +26 -32
  7. package/docs/system-architecture.md +45 -82
  8. package/package.json +4 -3
  9. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-01-delete-dead-code.md +88 -0
  10. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-02-cli-redesign.md +316 -0
  11. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-03-symlink-venv.md +148 -0
  12. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-04-wire-unused-functions.md +151 -0
  13. package/plans/260223-1310-v02-cli-redesign-cleanup/phase-05-safety-tests-docs.md +206 -0
  14. package/plans/260223-1310-v02-cli-redesign-cleanup/plan.md +18 -0
  15. package/plans/reports/planner-260223-v02-cli-redesign-cleanup-validation.md +95 -0
  16. package/plans/reports/project-manager-260223-v02-cli-redesign-cleanup-finalization.md +28 -0
  17. package/src/claudekit_codex_sync/asset_sync_dir.py +60 -9
  18. package/src/claudekit_codex_sync/asset_sync_zip.py +16 -5
  19. package/src/claudekit_codex_sync/clean_target.py +49 -0
  20. package/src/claudekit_codex_sync/cli.py +113 -81
  21. package/src/claudekit_codex_sync/constants.py +2 -13
  22. package/src/claudekit_codex_sync/dep_bootstrapper.py +71 -29
  23. package/src/claudekit_codex_sync/path_normalizer.py +2 -0
  24. package/src/claudekit_codex_sync/source_resolver.py +10 -2
  25. package/src/claudekit_codex_sync/sync_registry.py +1 -0
  26. package/templates/agents-md.md +5 -8
  27. package/tests/test_clean_target.py +55 -0
  28. package/tests/test_cli_args.py +57 -0
  29. package/reports/brainstorm-260222-2051-claudekit-codex-community-sync.md +0 -113
  30. package/scripts/bootstrap-claudekit-skill-scripts.sh +0 -150
  31. package/scripts/claudekit-sync-all.py +0 -1150
  32. package/scripts/export-claudekit-prompts.sh +0 -221
  33. package/scripts/normalize-claudekit-for-codex.sh +0 -261
@@ -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,49 @@
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
+ continue
27
+ if item.is_dir():
28
+ count = sum(1 for path in item.rglob("*") if path.is_file())
29
+ removed += count
30
+ if not dry_run:
31
+ shutil.rmtree(item)
32
+ else:
33
+ removed += 1
34
+ if not dry_run:
35
+ item.unlink()
36
+ print("fresh: rm skills/* (kept .venv)")
37
+
38
+ for name in (
39
+ ".claudekit-sync-registry.json",
40
+ ".sync-manifest-assets.txt",
41
+ ".claudekit-generated-prompts.txt",
42
+ ):
43
+ target = codex_home / name
44
+ if target.exists():
45
+ removed += 1
46
+ if not dry_run:
47
+ target.unlink()
48
+
49
+ return removed
@@ -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,34 +10,88 @@ 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
82
+
83
+ if not symlinked:
84
+ if not shutil.which("python3"):
85
+ from .utils import SyncError
86
+
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)
33
90
 
34
91
  req_files = sorted(skills_dir.rglob("requirements*.txt"))
35
92
  for req in req_files:
36
- rel = req.relative_to(skills_dir).as_posix()
37
93
  if is_excluded_path(req.parts):
38
94
  continue
39
- if not include_test_deps and "/test" in rel:
40
- continue
41
95
  if not include_mcp and ("mcp-builder" in req.parts or "mcp-management" in req.parts):
42
96
  continue
43
97
  try:
@@ -47,23 +101,11 @@ def bootstrap_deps(
47
101
  py_fail += 1
48
102
  eprint(f"python deps failed: {req}")
49
103
 
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):
56
- continue
57
- if not include_mcp and ("mcp-builder" in pkg.parts or "mcp-management" in pkg.parts):
58
- continue
59
- try:
60
- run_cmd([npm, "install", "--prefix", str(pkg.parent)], dry_run=dry_run)
61
- node_ok += 1
62
- except subprocess.CalledProcessError:
63
- node_fail += 1
64
- eprint(f"node deps failed: {pkg.parent}")
65
- else:
66
- eprint("npm not found; skipping Node dependency bootstrap")
104
+ node_ok, node_fail = _install_node_deps(
105
+ skills_dir=skills_dir,
106
+ include_mcp=include_mcp,
107
+ dry_run=dry_run,
108
+ )
67
109
 
68
110
  return {
69
111
  "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")):