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.
- package/AGENTS.md +5 -8
- 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 +10 -4
- 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 +59 -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 +82 -36
- package/src/claudekit_codex_sync/path_normalizer.py +2 -0
- package/src/claudekit_codex_sync/runtime_verifier.py +17 -7
- 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 +75 -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
|
@@ -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,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
|
-
|
|
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
|
|
33
82
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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([
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
eprint(f"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
|
package/templates/agents-md.md
CHANGED
|
@@ -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
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
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()
|