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.
- package/README.md +76 -59
- package/docs/codebase-summary.md +40 -43
- package/docs/codex-vs-claude-agents.md +53 -42
- package/docs/installation-guide.md +67 -23
- package/docs/project-overview-pdr.md +20 -26
- package/docs/project-roadmap.md +26 -32
- package/docs/system-architecture.md +45 -82
- package/package.json +4 -3
- package/plans/260223-1310-v02-cli-redesign-cleanup/phase-01-delete-dead-code.md +88 -0
- package/plans/260223-1310-v02-cli-redesign-cleanup/phase-02-cli-redesign.md +316 -0
- package/plans/260223-1310-v02-cli-redesign-cleanup/phase-03-symlink-venv.md +148 -0
- package/plans/260223-1310-v02-cli-redesign-cleanup/phase-04-wire-unused-functions.md +151 -0
- package/plans/260223-1310-v02-cli-redesign-cleanup/phase-05-safety-tests-docs.md +206 -0
- package/plans/260223-1310-v02-cli-redesign-cleanup/plan.md +18 -0
- package/plans/reports/planner-260223-v02-cli-redesign-cleanup-validation.md +95 -0
- package/plans/reports/project-manager-260223-v02-cli-redesign-cleanup-finalization.md +28 -0
- package/src/claudekit_codex_sync/asset_sync_dir.py +60 -9
- package/src/claudekit_codex_sync/asset_sync_zip.py +16 -5
- package/src/claudekit_codex_sync/clean_target.py +49 -0
- package/src/claudekit_codex_sync/cli.py +113 -81
- package/src/claudekit_codex_sync/constants.py +2 -13
- package/src/claudekit_codex_sync/dep_bootstrapper.py +71 -29
- package/src/claudekit_codex_sync/path_normalizer.py +2 -0
- package/src/claudekit_codex_sync/source_resolver.py +10 -2
- package/src/claudekit_codex_sync/sync_registry.py +1 -0
- package/templates/agents-md.md +5 -8
- package/tests/test_clean_target.py +55 -0
- package/tests/test_cli_args.py +57 -0
- package/reports/brainstorm-260222-2051-claudekit-codex-community-sync.md +0 -113
- package/scripts/bootstrap-claudekit-skill-scripts.sh +0 -150
- package/scripts/claudekit-sync-all.py +0 -1150
- package/scripts/export-claudekit-prompts.sh +0 -221
- 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 .
|
|
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
|
-
|
|
23
|
-
|
|
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:
|
|
69
|
+
print(f"add: {rel_path}")
|
|
45
70
|
else:
|
|
46
71
|
updated += 1
|
|
47
|
-
print(f"update:
|
|
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:
|
|
108
|
+
print(f"add: {rel_path}")
|
|
61
109
|
else:
|
|
62
110
|
updated += 1
|
|
63
|
-
print(f"update:
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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 .
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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.
|
|
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"
|
|
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,
|
|
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.
|
|
90
|
-
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:
|
|
144
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
95
145
|
assets_stats = sync_assets(
|
|
96
|
-
zf,
|
|
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.
|
|
102
|
-
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
|
-
|
|
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.
|
|
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
|
-
|
|
119
|
-
agent_toml_changed
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
187
|
+
if not args.no_deps:
|
|
150
188
|
bootstrap_stats = bootstrap_deps(
|
|
151
189
|
codex_home=codex_home,
|
|
152
|
-
include_mcp=args.
|
|
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:
|
|
158
|
-
f"
|
|
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 =
|
|
164
|
-
|
|
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
|
-
"
|
|
177
|
-
"
|
|
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
|
-
"
|
|
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:
|
|
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 = {"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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")):
|