claudekit-codex-sync 0.1.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 (56) hide show
  1. package/AGENTS.md +45 -0
  2. package/README.md +131 -0
  3. package/bin/ck-codex-sync +12 -0
  4. package/bin/ck-codex-sync.js +9 -0
  5. package/docs/code-standards.md +62 -0
  6. package/docs/codebase-summary.md +83 -0
  7. package/docs/codex-vs-claude-agents.md +74 -0
  8. package/docs/installation-guide.md +64 -0
  9. package/docs/project-overview-pdr.md +44 -0
  10. package/docs/project-roadmap.md +51 -0
  11. package/docs/system-architecture.md +106 -0
  12. package/package.json +16 -0
  13. package/plans/260222-2051-claudekit-codex-community-sync/phase-01-productization.md +36 -0
  14. package/plans/260222-2051-claudekit-codex-community-sync/phase-02-core-refactor.md +32 -0
  15. package/plans/260222-2051-claudekit-codex-community-sync/phase-03-agent-transpiler.md +33 -0
  16. package/plans/260222-2051-claudekit-codex-community-sync/phase-04-parity-harness.md +43 -0
  17. package/plans/260222-2051-claudekit-codex-community-sync/phase-05-distribution-npm.md +35 -0
  18. package/plans/260222-2051-claudekit-codex-community-sync/phase-06-git-clone-docs.md +28 -0
  19. package/plans/260222-2051-claudekit-codex-community-sync/phase-07-qa-release.md +35 -0
  20. package/plans/260222-2051-claudekit-codex-community-sync/plan.md +99 -0
  21. package/plans/260223-0951-refactor-and-upgrade/phase-01-project-structure.md +79 -0
  22. package/plans/260223-0951-refactor-and-upgrade/phase-02-extract-templates.md +36 -0
  23. package/plans/260223-0951-refactor-and-upgrade/phase-03-modularize-python.md +107 -0
  24. package/plans/260223-0951-refactor-and-upgrade/phase-04-live-source-detection.md +76 -0
  25. package/plans/260223-0951-refactor-and-upgrade/phase-05-agent-toml-config.md +88 -0
  26. package/plans/260223-0951-refactor-and-upgrade/phase-06-backup-registry.md +58 -0
  27. package/plans/260223-0951-refactor-and-upgrade/phase-07-tests-docs-push.md +54 -0
  28. package/plans/260223-0951-refactor-and-upgrade/plan.md +72 -0
  29. package/reports/brainstorm-260222-2051-claudekit-codex-community-sync.md +113 -0
  30. package/scripts/bootstrap-claudekit-skill-scripts.sh +150 -0
  31. package/scripts/claudekit-sync-all.py +1150 -0
  32. package/scripts/export-claudekit-prompts.sh +221 -0
  33. package/scripts/normalize-claudekit-for-codex.sh +261 -0
  34. package/src/claudekit_codex_sync/__init__.py +0 -0
  35. package/src/claudekit_codex_sync/asset_sync_dir.py +125 -0
  36. package/src/claudekit_codex_sync/asset_sync_zip.py +140 -0
  37. package/src/claudekit_codex_sync/bridge_generator.py +33 -0
  38. package/src/claudekit_codex_sync/cli.py +199 -0
  39. package/src/claudekit_codex_sync/config_enforcer.py +140 -0
  40. package/src/claudekit_codex_sync/constants.py +104 -0
  41. package/src/claudekit_codex_sync/dep_bootstrapper.py +73 -0
  42. package/src/claudekit_codex_sync/path_normalizer.py +248 -0
  43. package/src/claudekit_codex_sync/prompt_exporter.py +89 -0
  44. package/src/claudekit_codex_sync/runtime_verifier.py +32 -0
  45. package/src/claudekit_codex_sync/source_resolver.py +78 -0
  46. package/src/claudekit_codex_sync/sync_registry.py +77 -0
  47. package/src/claudekit_codex_sync/utils.py +130 -0
  48. package/templates/agents-md.md +45 -0
  49. package/templates/bridge-docs-init.sh +25 -0
  50. package/templates/bridge-project-status.sh +49 -0
  51. package/templates/bridge-resolve-command.py +52 -0
  52. package/templates/bridge-skill.md +63 -0
  53. package/templates/command-map.md +44 -0
  54. package/tests/__init__.py +1 -0
  55. package/tests/test_config_enforcer.py +44 -0
  56. package/tests/test_path_normalizer.py +61 -0
@@ -0,0 +1,1150 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ All-in-one ClaudeKit -> Codex sync script.
4
+
5
+ Features:
6
+ - Auto-detect newest ClaudeKit zip from temp directories
7
+ - Sync non-skill assets into ~/.codex/claudekit
8
+ - Sync skills into ~/.codex/skills
9
+ - Re-apply Codex compatibility customizations (paths, bridge skill, copywriting patch)
10
+ - Synthesize AGENTS.md
11
+ - Enforce Codex config defaults
12
+ - Export prompts to ~/.codex/prompts
13
+ - Bootstrap Python/Node dependencies
14
+ - Verify runtime health
15
+
16
+ Designed to run on Linux/macOS/WSL with Python 3.9+.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import shutil
26
+ import stat
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ import zipfile
31
+ from pathlib import Path
32
+ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
33
+
34
+
35
+ ASSET_DIRS = {"agents", "commands", "output-styles", "rules", "scripts"}
36
+ ASSET_FILES = {
37
+ "CLAUDE.md",
38
+ ".ck.json",
39
+ ".ckignore",
40
+ ".env.example",
41
+ ".mcp.json.example",
42
+ "settings.json",
43
+ "metadata.json",
44
+ "statusline.cjs",
45
+ "statusline.sh",
46
+ "statusline.ps1",
47
+ }
48
+
49
+ EXCLUDED_SKILLS_ALWAYS = {"template-skill"}
50
+ MCP_SKILLS = {"mcp-builder", "mcp-management"}
51
+ CONFLICT_SKILLS = {"skill-creator"}
52
+
53
+ PROMPT_MANIFEST = ".claudekit-generated-prompts.txt"
54
+ ASSET_MANIFEST = ".sync-manifest-assets.txt"
55
+
56
+
57
+ AGENTS_TEMPLATE = """# AGENTS.md
58
+
59
+ Codex working profile for this workspace, adapted from ClaudeKit rules and workflows.
60
+
61
+ ## Operating Principles
62
+
63
+ - Follow `YAGNI`, `KISS`, `DRY`.
64
+ - Prefer direct, maintainable solutions over speculative abstraction.
65
+ - Do not claim completion without evidence (tests, checks, or concrete validation).
66
+ - Never use fake implementations just to make tests/build pass.
67
+
68
+ ## Default Workflow
69
+
70
+ 1. Read context first: `README.md` and relevant docs under `./docs/`.
71
+ 2. For non-trivial work, create/update a plan in `./plans/` before coding.
72
+ 3. Implement in existing files unless new files are clearly needed.
73
+ 4. Validate with project compile/lint/test commands.
74
+ 5. Run code-review mindset before finalizing (bugs, regressions, missing tests first).
75
+ 6. Update docs when behavior, architecture, contracts, or operations change.
76
+
77
+ ## Quality Gates
78
+
79
+ - Handle edge cases and error paths explicitly.
80
+ - Keep security and performance implications visible in design decisions.
81
+ - Keep code readable and intention-revealing; add comments only when needed for non-obvious logic.
82
+
83
+ ## Documentation Rules
84
+
85
+ - `./docs` is the source of truth for project docs.
86
+ - Keep docs synchronized with code and implementation decisions.
87
+ - When summarizing/reporting, be concise and list unresolved questions at the end.
88
+
89
+ ## Skill Usage
90
+
91
+ - Activate relevant skills intentionally per task.
92
+ - For legacy ClaudeKit command intents (`/ck-help`, `/coding-level`, `/ask`, `/docs/*`, `/journal`, `/watzup`), use `$claudekit-command-bridge`.
93
+
94
+ ## Reference Material (Imported from ClaudeKit)
95
+
96
+ - `~/.codex/claudekit/CLAUDE.md`
97
+ - `~/.codex/claudekit/rules/development-rules.md`
98
+ - `~/.codex/claudekit/rules/primary-workflow.md`
99
+ - `~/.codex/claudekit/rules/orchestration-protocol.md`
100
+ - `~/.codex/claudekit/rules/documentation-management.md`
101
+ - `~/.codex/claudekit/rules/team-coordination-rules.md`
102
+ """
103
+
104
+
105
+ COMMAND_MAP_TEMPLATE = """# ClaudeKit -> Codex Command Map
106
+
107
+ ## Covered by existing skills
108
+
109
+ - `/preview` -> `markdown-novel-viewer`
110
+ - `/kanban` -> `plans-kanban`
111
+ - `/review/codebase` -> `code-review`
112
+ - `/test`, `/test/ui` -> `web-testing`
113
+ - `/worktree` -> `git`
114
+ - `/plan/*` -> `plan`
115
+
116
+ ## Converted into bridge workflows
117
+
118
+ - `/ck-help` -> `claudekit-command-bridge` (`resolve-command.py`)
119
+ - `/coding-level` -> `claudekit-command-bridge` (depth rubric + output styles)
120
+ - `/ask` -> `claudekit-command-bridge` (architecture mode)
121
+ - `/docs/init`, `/docs/update`, `/docs/summarize` -> `claudekit-command-bridge`
122
+ - `/journal`, `/watzup` -> `claudekit-command-bridge`
123
+
124
+ ## Explicitly excluded in this sync
125
+
126
+ - `/use-mcp` (excluded when `--include-mcp` is not set)
127
+ - Hooks (excluded when `--include-hooks` is not set)
128
+
129
+ ## Custom Prompt Aliases (`/prompts:<name>`)
130
+
131
+ - `/ask` -> `/prompts:ask`
132
+ - `/ck-help` -> `/prompts:ck-help`
133
+ - `/coding-level` -> `/prompts:coding-level`
134
+ - `/docs/init` -> `/prompts:docs-init`
135
+ - `/docs/summarize` -> `/prompts:docs-summarize`
136
+ - `/docs/update` -> `/prompts:docs-update`
137
+ - `/journal` -> `/prompts:journal`
138
+ - `/kanban` -> `/prompts:kanban`
139
+ - `/plan/archive` -> `/prompts:plan-archive`
140
+ - `/plan/red-team` -> `/prompts:plan-red-team`
141
+ - `/plan/validate` -> `/prompts:plan-validate`
142
+ - `/preview` -> `/prompts:preview`
143
+ - `/review/codebase` -> `/prompts:review-codebase`
144
+ - `/review/codebase/parallel` -> `/prompts:review-codebase-parallel`
145
+ - `/test` -> `/prompts:test`
146
+ - `/test/ui` -> `/prompts:test-ui`
147
+ - `/watzup` -> `/prompts:watzup`
148
+ - `/worktree` -> `/prompts:worktree`
149
+ """
150
+
151
+
152
+ BRIDGE_SKILL_TEMPLATE = """---
153
+ name: claudekit-command-bridge
154
+ description: Bridge legacy ClaudeKit commands to Codex-native workflows. Use when users mention /ck-help, /coding-level, /ask, /docs/*, /journal, /watzup, or ask for Claude command equivalents.
155
+ ---
156
+
157
+ # ClaudeKit Command Bridge
158
+
159
+ Translate ClaudeKit command intent into Codex skills/workflows.
160
+
161
+ ## Quick Mapping
162
+
163
+ | Legacy command | Codex target |
164
+ |---|---|
165
+ | `/preview` | `markdown-novel-viewer` skill |
166
+ | `/kanban` | `plans-kanban` skill |
167
+ | `/review/codebase` | `code-review` skill |
168
+ | `/test` or `/test/ui` | `web-testing` skill |
169
+ | `/worktree` | `git` skill + git worktree commands |
170
+ | `/plan/*` | `plan` skill |
171
+ | `/docs/init` | Run `scripts/docs-init.sh` then review docs |
172
+ | `/docs/update` | Update docs from latest code changes |
173
+ | `/docs/summarize` | Summarize codebase into `docs/codebase-summary.md` |
174
+ | `/journal` | Write concise entry under `docs/journals/` |
175
+ | `/watzup` | Produce status report from plans + git state |
176
+ | `/ask` | Architecture consultation mode (no implementation) |
177
+ | `/coding-level` | Adjust explanation depth (levels 0-5 rubric below) |
178
+ | `/ck-help` | Run `scripts/resolve-command.py "<request>"` |
179
+
180
+ ## Commands Converted Here
181
+
182
+ ### `/ask` -> Architecture mode
183
+
184
+ - Provide architecture analysis, tradeoffs, risks, and phased strategy.
185
+ - Do not start implementation unless user explicitly asks.
186
+
187
+ ### `/coding-level` -> Explanation depth policy
188
+
189
+ Use requested level when explaining:
190
+
191
+ - `0`: ELI5, minimal jargon, analogies.
192
+ - `1`: Junior, explain why and common mistakes.
193
+ - `2`: Mid, include patterns and tradeoffs.
194
+ - `3`: Senior, architecture and constraints focus.
195
+ - `4`: Lead, risk/business impact and strategy.
196
+ - `5`: Expert, concise implementation-first.
197
+
198
+ ### `/docs/init`, `/docs/update`, `/docs/summarize`
199
+
200
+ - Initialize docs structure with `scripts/docs-init.sh`.
201
+ - Keep docs source of truth under `./docs`.
202
+
203
+ ### `/journal`, `/watzup`
204
+
205
+ - Write concise journal entries in `docs/journals/`.
206
+ - For status, summarize plans and git state.
207
+
208
+ ## Helper Scripts
209
+
210
+ ```bash
211
+ python3 ${CODEX_HOME:-$HOME/.codex}/skills/claudekit-command-bridge/scripts/resolve-command.py "/docs/update"
212
+ ${CODEX_HOME:-$HOME/.codex}/skills/claudekit-command-bridge/scripts/docs-init.sh
213
+ ${CODEX_HOME:-$HOME/.codex}/skills/claudekit-command-bridge/scripts/project-status.sh
214
+ ```
215
+ """
216
+
217
+
218
+ BRIDGE_RESOLVE_SCRIPT = """#!/usr/bin/env python3
219
+ import sys
220
+
221
+ MAP = {
222
+ "/preview": "markdown-novel-viewer",
223
+ "/kanban": "plans-kanban",
224
+ "/review/codebase": "code-review",
225
+ "/test": "web-testing",
226
+ "/test/ui": "web-testing",
227
+ "/worktree": "git",
228
+ "/plan": "plan",
229
+ "/plan/validate": "plan",
230
+ "/plan/archive": "project-management",
231
+ "/plan/red-team": "plan",
232
+ "/docs/init": "claudekit-command-bridge (docs-init.sh)",
233
+ "/docs/update": "claudekit-command-bridge",
234
+ "/docs/summarize": "claudekit-command-bridge",
235
+ "/journal": "claudekit-command-bridge",
236
+ "/watzup": "claudekit-command-bridge",
237
+ "/ask": "claudekit-command-bridge (architecture mode)",
238
+ "/coding-level": "claudekit-command-bridge (explanation depth)",
239
+ "/ck-help": "claudekit-command-bridge (this resolver)",
240
+ }
241
+
242
+
243
+ def main() -> int:
244
+ raw = " ".join(sys.argv[1:]).strip()
245
+ if not raw:
246
+ print('Usage: resolve-command.py "<legacy-command-or-intent>"')
247
+ return 1
248
+
249
+ cmd = raw.split()[0]
250
+ if cmd in MAP:
251
+ print(f"{cmd} -> {MAP[cmd]}")
252
+ return 0
253
+
254
+ for prefix, target in [
255
+ ("/docs/", "claudekit-command-bridge"),
256
+ ("/plan/", "plan"),
257
+ ("/review/", "code-review"),
258
+ ("/test", "web-testing"),
259
+ ]:
260
+ if cmd.startswith(prefix):
261
+ print(f"{cmd} -> {target}")
262
+ return 0
263
+
264
+ print(f"{cmd} -> no direct map; use find-skills + claudekit-command-bridge")
265
+ return 0
266
+
267
+
268
+ if __name__ == "__main__":
269
+ raise SystemExit(main())
270
+ """
271
+
272
+
273
+ BRIDGE_DOCS_INIT_SCRIPT = """#!/usr/bin/env bash
274
+ set -euo pipefail
275
+
276
+ DOCS_DIR="${1:-docs}"
277
+ mkdir -p "$DOCS_DIR"
278
+ mkdir -p "$DOCS_DIR/journals"
279
+
280
+ create_if_missing() {
281
+ local file="$1"
282
+ local content="$2"
283
+ if [[ ! -f "$file" ]]; then
284
+ printf "%s\\n" "$content" > "$file"
285
+ echo "created: $file"
286
+ else
287
+ echo "exists: $file"
288
+ fi
289
+ }
290
+
291
+ create_if_missing "$DOCS_DIR/project-overview-pdr.md" "# Project Overview / PDR"
292
+ create_if_missing "$DOCS_DIR/code-standards.md" "# Code Standards"
293
+ create_if_missing "$DOCS_DIR/codebase-summary.md" "# Codebase Summary"
294
+ create_if_missing "$DOCS_DIR/design-guidelines.md" "# Design Guidelines"
295
+ create_if_missing "$DOCS_DIR/deployment-guide.md" "# Deployment Guide"
296
+ create_if_missing "$DOCS_DIR/system-architecture.md" "# System Architecture"
297
+ create_if_missing "$DOCS_DIR/project-roadmap.md" "# Project Roadmap"
298
+ """
299
+
300
+
301
+ BRIDGE_STATUS_SCRIPT = """#!/usr/bin/env bash
302
+ set -euo pipefail
303
+
304
+ PLANS_DIR="${1:-plans}"
305
+
306
+ echo "## Project Status"
307
+ echo
308
+
309
+ if [[ -d "$PLANS_DIR" ]]; then
310
+ total_plans=0
311
+ completed=0
312
+ in_progress=0
313
+ pending=0
314
+
315
+ while IFS= read -r plan; do
316
+ total_plans=$((total_plans + 1))
317
+ status="$(grep -E '^status:' "$plan" | head -n1 | awk '{print $2}')"
318
+ case "$status" in
319
+ completed) completed=$((completed + 1)) ;;
320
+ in-progress|in_progress) in_progress=$((in_progress + 1)) ;;
321
+ pending|"") pending=$((pending + 1)) ;;
322
+ *) ;;
323
+ esac
324
+ done < <(find "$PLANS_DIR" -type f -name plan.md | sort)
325
+
326
+ echo "- Plans: $total_plans"
327
+ echo "- Completed: $completed"
328
+ echo "- In progress: $in_progress"
329
+ echo "- Pending/unknown: $pending"
330
+ echo
331
+ else
332
+ echo "- Plans directory not found: $PLANS_DIR"
333
+ echo
334
+ fi
335
+
336
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
337
+ echo "## Git"
338
+ echo
339
+ echo "- Branch: $(git rev-parse --abbrev-ref HEAD)"
340
+ if [[ -n "$(git status --porcelain)" ]]; then
341
+ echo "- Working tree: dirty"
342
+ else
343
+ echo "- Working tree: clean"
344
+ fi
345
+ else
346
+ echo "## Git"
347
+ echo
348
+ echo "- Not in a git repository"
349
+ fi
350
+ """
351
+
352
+
353
+ SKILL_MD_REPLACEMENTS: List[Tuple[str, str]] = [
354
+ ("$HOME/.claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
355
+ ("$HOME/.claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
356
+ ("$HOME/.claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
357
+ ("$HOME/.claude/", "${CODEX_HOME:-$HOME/.codex}/"),
358
+ ("./.claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
359
+ (".claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
360
+ ("./.claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
361
+ (".claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
362
+ ("./.claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
363
+ (".claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
364
+ ("~/.claude/.ck.json", "~/.codex/claudekit/.ck.json"),
365
+ ("./.claude/.ck.json", "~/.codex/claudekit/.ck.json"),
366
+ (".claude/.ck.json", "~/.codex/claudekit/.ck.json"),
367
+ ("~/.claude/", "~/.codex/"),
368
+ ("./.claude/", "./.codex/"),
369
+ ("<project>/.claude/", "<project>/.codex/"),
370
+ (".claude/", ".codex/"),
371
+ ("`.claude`", "`.codex`"),
372
+ ("$HOME/${CODEX_HOME:-$HOME/.codex}/", "${CODEX_HOME:-$HOME/.codex}/"),
373
+ ]
374
+
375
+
376
+ PROMPT_REPLACEMENTS: List[Tuple[str, str]] = [
377
+ ("$HOME/.claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
378
+ ("$HOME/.claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
379
+ ("$HOME/.claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
380
+ ("$HOME/.claude/", "${CODEX_HOME:-$HOME/.codex}/"),
381
+ ("./.claude/skills/", "~/.codex/skills/"),
382
+ (".claude/skills/", "~/.codex/skills/"),
383
+ ("./.claude/scripts/", "~/.codex/claudekit/scripts/"),
384
+ (".claude/scripts/", "~/.codex/claudekit/scripts/"),
385
+ ("./.claude/rules/", "~/.codex/claudekit/rules/"),
386
+ (".claude/rules/", "~/.codex/claudekit/rules/"),
387
+ ("~/.claude/.ck.json", "~/.codex/claudekit/.ck.json"),
388
+ ("./.claude/.ck.json", "~/.codex/claudekit/.ck.json"),
389
+ (".claude/.ck.json", "~/.codex/claudekit/.ck.json"),
390
+ ("$HOME/${CODEX_HOME:-$HOME/.codex}/", "${CODEX_HOME:-$HOME/.codex}/"),
391
+ ]
392
+
393
+
394
+ class SyncError(RuntimeError):
395
+ pass
396
+
397
+
398
+ def eprint(msg: str) -> None:
399
+ print(msg, file=sys.stderr)
400
+
401
+
402
+ def run_cmd(
403
+ cmd: Sequence[str],
404
+ *,
405
+ cwd: Optional[Path] = None,
406
+ dry_run: bool = False,
407
+ check: bool = True,
408
+ capture: bool = False,
409
+ ) -> subprocess.CompletedProcess:
410
+ pretty = " ".join(cmd)
411
+ if dry_run:
412
+ print(f"[dry-run] {pretty}")
413
+ return subprocess.CompletedProcess(cmd, 0, "", "")
414
+ return subprocess.run(
415
+ list(cmd),
416
+ cwd=str(cwd) if cwd else None,
417
+ check=check,
418
+ text=True,
419
+ capture_output=capture,
420
+ )
421
+
422
+
423
+ def ensure_parent(path: Path, dry_run: bool) -> None:
424
+ if dry_run:
425
+ return
426
+ path.parent.mkdir(parents=True, exist_ok=True)
427
+
428
+
429
+ def write_bytes_if_changed(path: Path, data: bytes, *, mode: Optional[int], dry_run: bool) -> Tuple[bool, bool]:
430
+ exists = path.exists()
431
+ if exists and path.read_bytes() == data:
432
+ if mode is not None and not dry_run:
433
+ os.chmod(path, mode)
434
+ return False, False
435
+ if dry_run:
436
+ return True, not exists
437
+ ensure_parent(path, dry_run=False)
438
+ path.write_bytes(data)
439
+ if mode is not None:
440
+ os.chmod(path, mode)
441
+ return True, not exists
442
+
443
+
444
+ def write_text_if_changed(path: Path, text: str, *, executable: bool = False, dry_run: bool = False) -> bool:
445
+ mode = None
446
+ if executable:
447
+ mode = 0o755
448
+ data = text.encode("utf-8")
449
+ changed, _ = write_bytes_if_changed(path, data, mode=mode, dry_run=dry_run)
450
+ return changed
451
+
452
+
453
+ def zip_mode(info: zipfile.ZipInfo) -> Optional[int]:
454
+ unix_mode = (info.external_attr >> 16) & 0o777
455
+ if unix_mode:
456
+ return unix_mode
457
+ return None
458
+
459
+
460
+ def find_latest_zip(explicit_zip: Optional[Path]) -> Path:
461
+ if explicit_zip:
462
+ p = explicit_zip.expanduser().resolve()
463
+ if not p.exists():
464
+ raise SyncError(f"Zip not found: {p}")
465
+ return p
466
+
467
+ candidates: List[Path] = []
468
+ roots = {Path("/tmp"), Path(tempfile.gettempdir())}
469
+ for root in roots:
470
+ if root.exists():
471
+ candidates.extend(root.glob("claudekit-*/*.zip"))
472
+
473
+ if not candidates:
474
+ raise SyncError("No ClaudeKit zip found. Expected /tmp/claudekit-*/*.zip")
475
+
476
+ latest = max(candidates, key=lambda p: p.stat().st_mtime)
477
+ return latest.resolve()
478
+
479
+
480
+ def load_manifest(path: Path) -> Set[str]:
481
+ if not path.exists():
482
+ return set()
483
+ return {line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()}
484
+
485
+
486
+ def save_manifest(path: Path, values: Iterable[str], dry_run: bool) -> None:
487
+ data = "\n".join(sorted(set(values)))
488
+ if data:
489
+ data += "\n"
490
+ write_text_if_changed(path, data, dry_run=dry_run)
491
+
492
+
493
+ def apply_replacements(text: str, rules: Sequence[Tuple[str, str]]) -> str:
494
+ out = text
495
+ for old, new in rules:
496
+ out = out.replace(old, new)
497
+ return out
498
+
499
+
500
+ def is_excluded_path(parts: Sequence[str]) -> bool:
501
+ blocked = {".system", "node_modules", ".venv", "dist", "build", "__pycache__", ".pytest_cache"}
502
+ return any(p in blocked for p in parts)
503
+
504
+
505
+ def sync_assets(
506
+ zf: zipfile.ZipFile,
507
+ *,
508
+ codex_home: Path,
509
+ include_hooks: bool,
510
+ dry_run: bool,
511
+ ) -> Dict[str, int]:
512
+ claudekit_dir = codex_home / "claudekit"
513
+ manifest_path = claudekit_dir / ASSET_MANIFEST
514
+ old_manifest = load_manifest(manifest_path)
515
+
516
+ selected: List[Tuple[str, str]] = []
517
+ for name in zf.namelist():
518
+ if name.endswith("/") or not name.startswith(".claude/"):
519
+ continue
520
+ rel = name[len(".claude/") :]
521
+ first = rel.split("/", 1)[0]
522
+ if first == "hooks" and include_hooks:
523
+ selected.append((name, rel))
524
+ continue
525
+ if first in ASSET_DIRS or rel in ASSET_FILES:
526
+ selected.append((name, rel))
527
+
528
+ new_manifest = {rel for _, rel in selected}
529
+
530
+ added = 0
531
+ updated = 0
532
+ removed = 0
533
+
534
+ # Remove stale managed files.
535
+ stale = sorted(old_manifest - new_manifest)
536
+ for rel in stale:
537
+ target = claudekit_dir / rel
538
+ if target.exists():
539
+ removed += 1
540
+ print(f"remove: {rel}")
541
+ if not dry_run:
542
+ target.unlink()
543
+
544
+ # Write assets.
545
+ for zip_name, rel in sorted(selected, key=lambda x: x[1]):
546
+ info = zf.getinfo(zip_name)
547
+ data = zf.read(zip_name)
548
+ dst = claudekit_dir / rel
549
+ changed, is_added = write_bytes_if_changed(dst, data, mode=zip_mode(info), dry_run=dry_run)
550
+ if not changed:
551
+ continue
552
+ if is_added:
553
+ added += 1
554
+ print(f"add: {rel}")
555
+ else:
556
+ updated += 1
557
+ print(f"update: {rel}")
558
+
559
+ if not dry_run:
560
+ claudekit_dir.mkdir(parents=True, exist_ok=True)
561
+ save_manifest(manifest_path, new_manifest, dry_run=dry_run)
562
+
563
+ if not dry_run:
564
+ # Clean empty folders.
565
+ for d in sorted(claudekit_dir.rglob("*"), reverse=True):
566
+ if d.is_dir():
567
+ try:
568
+ d.rmdir()
569
+ except OSError:
570
+ pass
571
+
572
+ return {
573
+ "added": added,
574
+ "updated": updated,
575
+ "removed": removed,
576
+ "managed_files": len(new_manifest),
577
+ }
578
+
579
+
580
+ def collect_skill_entries(zf: zipfile.ZipFile) -> Dict[str, List[Tuple[str, str]]]:
581
+ skill_files: Dict[str, List[Tuple[str, str]]] = {}
582
+ for name in zf.namelist():
583
+ if name.endswith("/") or not name.startswith(".claude/skills/"):
584
+ continue
585
+ rel = name[len(".claude/skills/") :]
586
+ parts = rel.split("/", 1)
587
+ if len(parts) != 2:
588
+ continue
589
+ skill, inner = parts
590
+ skill_files.setdefault(skill, []).append((name, inner))
591
+ return skill_files
592
+
593
+
594
+ def sync_skills(
595
+ zf: zipfile.ZipFile,
596
+ *,
597
+ codex_home: Path,
598
+ include_mcp: bool,
599
+ include_conflicts: bool,
600
+ dry_run: bool,
601
+ ) -> Dict[str, int]:
602
+ skills_dir = codex_home / "skills"
603
+ skill_entries = collect_skill_entries(zf)
604
+
605
+ added = 0
606
+ updated = 0
607
+ skipped = 0
608
+
609
+ for skill in sorted(skill_entries):
610
+ if skill in EXCLUDED_SKILLS_ALWAYS:
611
+ skipped += 1
612
+ print(f"skip: {skill}")
613
+ continue
614
+ if not include_mcp and skill in MCP_SKILLS:
615
+ skipped += 1
616
+ print(f"skip: {skill}")
617
+ continue
618
+ if skill in CONFLICT_SKILLS:
619
+ skipped += 1
620
+ print(f"skip: {skill}")
621
+ continue
622
+ if not include_conflicts and (skills_dir / ".system" / skill).exists():
623
+ skipped += 1
624
+ print(f"skip: {skill}")
625
+ continue
626
+
627
+ dst_skill_dir = skills_dir / skill
628
+ exists = dst_skill_dir.exists()
629
+ if exists:
630
+ updated += 1
631
+ print(f"update: {skill}")
632
+ else:
633
+ added += 1
634
+ print(f"add: {skill}")
635
+
636
+ if dry_run:
637
+ continue
638
+
639
+ if exists:
640
+ shutil.rmtree(dst_skill_dir)
641
+ dst_skill_dir.mkdir(parents=True, exist_ok=True)
642
+
643
+ for zip_name, inner in sorted(skill_entries[skill], key=lambda x: x[1]):
644
+ info = zf.getinfo(zip_name)
645
+ data = zf.read(zip_name)
646
+ dst = dst_skill_dir / inner
647
+ write_bytes_if_changed(dst, data, mode=zip_mode(info), dry_run=False)
648
+
649
+ skills_dir.mkdir(parents=True, exist_ok=True)
650
+ total_skills = len(list(skills_dir.rglob("SKILL.md")))
651
+ return {
652
+ "added": added,
653
+ "updated": updated,
654
+ "skipped": skipped,
655
+ "total_skills": total_skills,
656
+ }
657
+
658
+
659
+ def patch_copywriting_script(copy_script: Path, *, dry_run: bool) -> bool:
660
+ if not copy_script.exists():
661
+ return False
662
+
663
+ text = copy_script.read_text(encoding="utf-8")
664
+ original = text
665
+ if "CODEX_HOME = Path(os.environ.get('CODEX_HOME'" in text:
666
+ return False
667
+
668
+ new_func = """def find_project_root(start_dir: Path) -> Path:
669
+ \"\"\"Find project root by preferring a directory that contains assets/writing-styles.\"\"\"
670
+ search_chain = [start_dir] + list(start_dir.parents)
671
+ for parent in search_chain:
672
+ if (parent / 'assets' / 'writing-styles').exists():
673
+ return parent
674
+ for parent in search_chain:
675
+ if (parent / 'SKILL.md').exists():
676
+ return parent
677
+ for parent in search_chain:
678
+ if (parent / '.codex').exists() or (parent / '.claude').exists():
679
+ return parent
680
+ return start_dir
681
+ """
682
+
683
+ text, count_func = re.subn(
684
+ r"def find_project_root\(start_dir: Path\) -> Path:\n(?: .*\n)+? return start_dir\n",
685
+ new_func,
686
+ text,
687
+ count=1,
688
+ )
689
+
690
+ new_block = """PROJECT_ROOT = find_project_root(Path(__file__).parent)
691
+ STYLES_DIR = PROJECT_ROOT / 'assets' / 'writing-styles'
692
+ CODEX_HOME = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex')))
693
+
694
+ _ai_multimodal_candidates = [
695
+ PROJECT_ROOT / '.claude' / 'skills' / 'ai-multimodal' / 'scripts',
696
+ CODEX_HOME / 'skills' / 'ai-multimodal' / 'scripts',
697
+ ]
698
+ AI_MULTIMODAL_SCRIPTS = next((p for p in _ai_multimodal_candidates if p.exists()), _ai_multimodal_candidates[-1])
699
+ """
700
+
701
+ text, count_block = re.subn(
702
+ r"PROJECT_ROOT = find_project_root\(Path\(__file__\)\.parent\)\nSTYLES_DIR = PROJECT_ROOT / 'assets' / 'writing-styles'\nAI_MULTIMODAL_SCRIPTS = PROJECT_ROOT / '.claude' / 'skills' / 'ai-multimodal' / 'scripts'\n",
703
+ new_block,
704
+ text,
705
+ count=1,
706
+ )
707
+
708
+ if count_func == 0 or count_block == 0:
709
+ raise SyncError("copywriting patch failed: upstream pattern changed")
710
+
711
+ if text == original:
712
+ return False
713
+ if not dry_run:
714
+ copy_script.write_text(text, encoding="utf-8")
715
+ return True
716
+
717
+
718
+ def normalize_files(
719
+ *,
720
+ codex_home: Path,
721
+ include_mcp: bool,
722
+ dry_run: bool,
723
+ ) -> int:
724
+ changed = 0
725
+ skills_dir = codex_home / "skills"
726
+ claudekit_dir = codex_home / "claudekit"
727
+
728
+ skill_files = sorted(skills_dir.rglob("SKILL.md"))
729
+ for path in skill_files:
730
+ if ".system" in path.parts:
731
+ continue
732
+ rel = path.relative_to(codex_home).as_posix()
733
+ if not include_mcp and any(m in rel for m in ("/mcp-builder/", "/mcp-management/")):
734
+ continue
735
+ text = path.read_text(encoding="utf-8", errors="ignore")
736
+ new_text = apply_replacements(text, SKILL_MD_REPLACEMENTS)
737
+ if new_text != text:
738
+ changed += 1
739
+ print(f"normalize: {rel}")
740
+ if not dry_run:
741
+ path.write_text(new_text, encoding="utf-8")
742
+
743
+ for path in sorted(claudekit_dir.rglob("*.md")):
744
+ rel = path.relative_to(codex_home).as_posix()
745
+ text = path.read_text(encoding="utf-8", errors="ignore")
746
+ new_text = apply_replacements(text, SKILL_MD_REPLACEMENTS)
747
+ if new_text != text:
748
+ changed += 1
749
+ print(f"normalize: {rel}")
750
+ if not dry_run:
751
+ path.write_text(new_text, encoding="utf-8")
752
+
753
+ copy_script = skills_dir / "copywriting" / "scripts" / "extract-writing-styles.py"
754
+ if patch_copywriting_script(copy_script, dry_run=dry_run):
755
+ changed += 1
756
+ print("normalize: skills/copywriting/scripts/extract-writing-styles.py")
757
+
758
+ default_style = skills_dir / "copywriting" / "assets" / "writing-styles" / "default.md"
759
+ fallback_style = skills_dir / "copywriting" / "references" / "writing-styles.md"
760
+ if not default_style.exists() and fallback_style.exists():
761
+ changed += 1
762
+ print("add: skills/copywriting/assets/writing-styles/default.md")
763
+ if not dry_run:
764
+ default_style.parent.mkdir(parents=True, exist_ok=True)
765
+ shutil.copy2(fallback_style, default_style)
766
+
767
+ command_map = codex_home / "claudekit" / "commands" / "codex-command-map.md"
768
+ if write_text_if_changed(command_map, COMMAND_MAP_TEMPLATE, dry_run=dry_run):
769
+ changed += 1
770
+ print("upsert: claudekit/commands/codex-command-map.md")
771
+
772
+ return changed
773
+
774
+
775
+ def ensure_bridge_skill(*, codex_home: Path, dry_run: bool) -> bool:
776
+ bridge_dir = codex_home / "skills" / "claudekit-command-bridge"
777
+ scripts_dir = bridge_dir / "scripts"
778
+ if not dry_run:
779
+ scripts_dir.mkdir(parents=True, exist_ok=True)
780
+ changed = False
781
+
782
+ changed |= write_text_if_changed(bridge_dir / "SKILL.md", BRIDGE_SKILL_TEMPLATE, dry_run=dry_run)
783
+ changed |= write_text_if_changed(
784
+ scripts_dir / "resolve-command.py", BRIDGE_RESOLVE_SCRIPT, executable=True, dry_run=dry_run
785
+ )
786
+ changed |= write_text_if_changed(
787
+ scripts_dir / "docs-init.sh", BRIDGE_DOCS_INIT_SCRIPT, executable=True, dry_run=dry_run
788
+ )
789
+ changed |= write_text_if_changed(
790
+ scripts_dir / "project-status.sh", BRIDGE_STATUS_SCRIPT, executable=True, dry_run=dry_run
791
+ )
792
+ return changed
793
+
794
+
795
+ def ensure_agents(*, workspace: Path, dry_run: bool) -> bool:
796
+ target = workspace / "AGENTS.md"
797
+ return write_text_if_changed(target, AGENTS_TEMPLATE, dry_run=dry_run)
798
+
799
+
800
+ def enforce_config(*, codex_home: Path, include_mcp: bool, dry_run: bool) -> bool:
801
+ config = codex_home / "config.toml"
802
+ if config.exists():
803
+ text = config.read_text(encoding="utf-8")
804
+ else:
805
+ text = ""
806
+ orig = text
807
+
808
+ if re.search(r"^project_doc_max_bytes\s*=", text, flags=re.M):
809
+ text = re.sub(r"^project_doc_max_bytes\s*=.*$", "project_doc_max_bytes = 65536", text, flags=re.M)
810
+ else:
811
+ text = (text.rstrip("\n") + "\nproject_doc_max_bytes = 65536\n").lstrip("\n")
812
+
813
+ fallback_line = 'project_doc_fallback_filenames = ["AGENTS.md", "CLAUDE.md", "AGENTS.override.md"]'
814
+ if re.search(r"^project_doc_fallback_filenames\s*=", text, flags=re.M):
815
+ text = re.sub(r"^project_doc_fallback_filenames\s*=.*$", fallback_line, text, flags=re.M)
816
+ else:
817
+ text = text.rstrip("\n") + "\n" + fallback_line + "\n"
818
+
819
+ mcp_management_path = str((codex_home / "skills" / "mcp-management").resolve())
820
+ mcp_builder_path = str((codex_home / "skills" / "mcp-builder").resolve())
821
+ mcp_enabled = "true" if include_mcp else "false"
822
+
823
+ pattern = re.compile(r"\n\[\[skills\.config\]\]\n(?:[^\n]*\n)*?(?=\n\[\[skills\.config\]\]|\Z)", re.M)
824
+ blocks = pattern.findall("\n" + text)
825
+ kept: List[str] = []
826
+ for block in blocks:
827
+ if f'path = "{mcp_management_path}"' in block:
828
+ continue
829
+ if f'path = "{mcp_builder_path}"' in block:
830
+ continue
831
+ kept.append(block.rstrip("\n"))
832
+
833
+ base = pattern.sub("", "\n" + text).lstrip("\n").rstrip("\n")
834
+ for block in kept:
835
+ if block:
836
+ base += "\n\n" + block
837
+
838
+ base += f'\n\n[[skills.config]]\npath = "{mcp_management_path}"\nenabled = {mcp_enabled}\n'
839
+ base += f'\n[[skills.config]]\npath = "{mcp_builder_path}"\nenabled = {mcp_enabled}\n'
840
+
841
+ if base == orig:
842
+ return False
843
+ if not dry_run:
844
+ config.parent.mkdir(parents=True, exist_ok=True)
845
+ config.write_text(base, encoding="utf-8")
846
+ return True
847
+
848
+
849
+ def ensure_frontmatter(content: str, command_path: str) -> str:
850
+ if content.lstrip().startswith("---"):
851
+ return content
852
+ return f"---\ndescription: ClaudeKit compatibility prompt for /{command_path}\n---\n\n{content}"
853
+
854
+
855
+ def export_prompts(
856
+ *,
857
+ codex_home: Path,
858
+ include_mcp: bool,
859
+ dry_run: bool,
860
+ ) -> Dict[str, int]:
861
+ source = codex_home / "claudekit" / "commands"
862
+ prompts_dir = codex_home / "prompts"
863
+ manifest_path = prompts_dir / PROMPT_MANIFEST
864
+
865
+ if not source.exists():
866
+ if dry_run:
867
+ print(f"skip: prompt export dry-run requires existing {source}")
868
+ return {"added": 0, "updated": 0, "skipped": 0, "removed": 0, "collisions": 0, "total_generated": 0}
869
+ raise SyncError(f"Prompt source directory not found: {source}")
870
+
871
+ old_manifest = load_manifest(manifest_path)
872
+
873
+ files = sorted(source.rglob("*.md"))
874
+ generated: Set[str] = set()
875
+ added = 0
876
+ updated = 0
877
+ skipped = 0
878
+ removed = 0
879
+ collisions = 0
880
+
881
+ if not dry_run:
882
+ prompts_dir.mkdir(parents=True, exist_ok=True)
883
+
884
+ for src in files:
885
+ rel = src.relative_to(source).as_posix()
886
+ base = src.name
887
+ if base == "codex-command-map.md":
888
+ skipped += 1
889
+ print(f"skip: {rel}")
890
+ continue
891
+ if base == "use-mcp.md" and not include_mcp:
892
+ skipped += 1
893
+ print(f"skip: {rel}")
894
+ continue
895
+
896
+ prompt_name = rel[:-3].replace("/", "-") + ".md"
897
+ dst = prompts_dir / prompt_name
898
+ text = src.read_text(encoding="utf-8", errors="ignore")
899
+ text = apply_replacements(text, PROMPT_REPLACEMENTS)
900
+ text = ensure_frontmatter(text, rel[:-3])
901
+ data = text.encode("utf-8")
902
+
903
+ if dst.exists() and prompt_name not in old_manifest:
904
+ collisions += 1
905
+ print(f"skip(collision): {prompt_name}")
906
+ continue
907
+
908
+ generated.add(prompt_name)
909
+ changed, is_added = write_bytes_if_changed(dst, data, mode=0o644, dry_run=dry_run)
910
+ if not changed:
911
+ continue
912
+ if is_added:
913
+ added += 1
914
+ print(f"add: {prompt_name} <= {rel}")
915
+ else:
916
+ updated += 1
917
+ print(f"update: {prompt_name} <= {rel}")
918
+
919
+ stale = sorted(old_manifest - generated)
920
+ for name in stale:
921
+ target = prompts_dir / name
922
+ if target.exists():
923
+ removed += 1
924
+ print(f"remove(stale): {name}")
925
+ if not dry_run:
926
+ target.unlink()
927
+
928
+ save_manifest(manifest_path, generated, dry_run=dry_run)
929
+
930
+ return {
931
+ "added": added,
932
+ "updated": updated,
933
+ "skipped": skipped,
934
+ "removed": removed,
935
+ "collisions": collisions,
936
+ "total_generated": len(generated),
937
+ }
938
+
939
+
940
+ def bootstrap_deps(
941
+ *,
942
+ codex_home: Path,
943
+ include_mcp: bool,
944
+ include_test_deps: bool,
945
+ dry_run: bool,
946
+ ) -> Dict[str, int]:
947
+ skills_dir = codex_home / "skills"
948
+ venv_dir = skills_dir / ".venv"
949
+
950
+ if not shutil.which("python3"):
951
+ raise SyncError("python3 not found")
952
+
953
+ py_ok = py_fail = node_ok = node_fail = 0
954
+
955
+ run_cmd(["python3", "-m", "venv", str(venv_dir)], dry_run=dry_run)
956
+ py_bin = venv_dir / "bin" / "python3"
957
+ run_cmd([str(py_bin), "-m", "pip", "install", "--upgrade", "pip"], dry_run=dry_run)
958
+
959
+ req_files = sorted(skills_dir.rglob("requirements*.txt"))
960
+ for req in req_files:
961
+ rel = req.relative_to(skills_dir).as_posix()
962
+ if is_excluded_path(req.parts):
963
+ continue
964
+ if not include_test_deps and "/test" in rel:
965
+ continue
966
+ if not include_mcp and ("mcp-builder" in req.parts or "mcp-management" in req.parts):
967
+ continue
968
+ try:
969
+ run_cmd([str(py_bin), "-m", "pip", "install", "-r", str(req)], dry_run=dry_run)
970
+ py_ok += 1
971
+ except subprocess.CalledProcessError:
972
+ py_fail += 1
973
+ eprint(f"python deps failed: {req}")
974
+
975
+ npm = shutil.which("npm")
976
+ if npm:
977
+ pkg_files = sorted(skills_dir.rglob("package.json"))
978
+ for pkg in pkg_files:
979
+ rel = pkg.relative_to(skills_dir).as_posix()
980
+ if is_excluded_path(pkg.parts):
981
+ continue
982
+ if not include_mcp and ("mcp-builder" in pkg.parts or "mcp-management" in pkg.parts):
983
+ continue
984
+ try:
985
+ run_cmd([npm, "install", "--prefix", str(pkg.parent)], dry_run=dry_run)
986
+ node_ok += 1
987
+ except subprocess.CalledProcessError:
988
+ node_fail += 1
989
+ eprint(f"node deps failed: {pkg.parent}")
990
+ else:
991
+ eprint("npm not found; skipping Node dependency bootstrap")
992
+
993
+ return {
994
+ "python_ok": py_ok,
995
+ "python_fail": py_fail,
996
+ "node_ok": node_ok,
997
+ "node_fail": node_fail,
998
+ }
999
+
1000
+
1001
+ def verify_runtime(*, codex_home: Path, dry_run: bool) -> Dict[str, object]:
1002
+ if dry_run:
1003
+ return {"skipped": True}
1004
+
1005
+ run_cmd(["codex", "--help"], dry_run=False)
1006
+
1007
+ copy_script = codex_home / "skills" / "copywriting" / "scripts" / "extract-writing-styles.py"
1008
+ py_bin = codex_home / "skills" / ".venv" / "bin" / "python3"
1009
+ copywriting_ok = False
1010
+ if copy_script.exists() and py_bin.exists():
1011
+ run_cmd([str(py_bin), str(copy_script), "--list"], dry_run=False)
1012
+ copywriting_ok = True
1013
+
1014
+ prompts_count = len(list((codex_home / "prompts").glob("*.md")))
1015
+ skills_count = len(list((codex_home / "skills").rglob("SKILL.md")))
1016
+ return {
1017
+ "codex_help": "ok",
1018
+ "copywriting": "ok" if copywriting_ok else "skipped",
1019
+ "prompts": prompts_count,
1020
+ "skills": skills_count,
1021
+ }
1022
+
1023
+
1024
+ def print_summary(summary: Dict[str, object]) -> None:
1025
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
1026
+
1027
+
1028
+ def parse_args() -> argparse.Namespace:
1029
+ p = argparse.ArgumentParser(
1030
+ description="All-in-one ClaudeKit -> Codex sync script (portable, no manual steps required)."
1031
+ )
1032
+ p.add_argument("--zip", dest="zip_path", type=Path, help="Specific ClaudeKit zip path")
1033
+ p.add_argument("--codex-home", type=Path, default=None, help="Codex home (default: $CODEX_HOME or ~/.codex)")
1034
+ p.add_argument("--workspace", type=Path, default=Path.cwd(), help="Workspace root for AGENTS.md")
1035
+ p.add_argument("--include-mcp", action="store_true", help="Include MCP skills/prompts and enable MCP skills")
1036
+ p.add_argument("--include-hooks", action="store_true", help="Include hooks under ~/.codex/claudekit/hooks")
1037
+ p.add_argument("--include-conflicts", action="store_true", help="Include skills conflicting with system skills")
1038
+ p.add_argument("--include-test-deps", action="store_true", help="Install test requirements in bootstrap")
1039
+ p.add_argument("--skip-bootstrap", action="store_true", help="Skip dependency bootstrap")
1040
+ p.add_argument("--skip-verify", action="store_true", help="Skip post-sync verification")
1041
+ p.add_argument("--dry-run", action="store_true", help="Preview changes only")
1042
+ return p.parse_args()
1043
+
1044
+
1045
+ def main() -> int:
1046
+ args = parse_args()
1047
+ codex_home = (args.codex_home or Path(os.environ.get("CODEX_HOME", "~/.codex"))).expanduser().resolve()
1048
+ workspace = args.workspace.expanduser().resolve()
1049
+ workspace.mkdir(parents=True, exist_ok=True)
1050
+
1051
+ zip_path = find_latest_zip(args.zip_path)
1052
+ print(f"zip: {zip_path}")
1053
+ print(f"codex_home: {codex_home}")
1054
+ print(f"workspace: {workspace}")
1055
+ print(
1056
+ f"include_mcp={args.include_mcp} include_hooks={args.include_hooks} dry_run={args.dry_run} "
1057
+ f"skip_bootstrap={args.skip_bootstrap} skip_verify={args.skip_verify}"
1058
+ )
1059
+
1060
+ codex_home.mkdir(parents=True, exist_ok=True)
1061
+
1062
+ with zipfile.ZipFile(zip_path) as zf:
1063
+ assets_stats = sync_assets(
1064
+ zf, codex_home=codex_home, include_hooks=args.include_hooks, dry_run=args.dry_run
1065
+ )
1066
+ print(
1067
+ f"assets: added={assets_stats['added']} updated={assets_stats['updated']} "
1068
+ f"removed={assets_stats['removed']} managed_files={assets_stats['managed_files']}"
1069
+ )
1070
+
1071
+ skills_stats = sync_skills(
1072
+ zf,
1073
+ codex_home=codex_home,
1074
+ include_mcp=args.include_mcp,
1075
+ include_conflicts=args.include_conflicts,
1076
+ dry_run=args.dry_run,
1077
+ )
1078
+ print(
1079
+ f"skills: added={skills_stats['added']} updated={skills_stats['updated']} "
1080
+ f"skipped={skills_stats['skipped']} total_skills={skills_stats['total_skills']}"
1081
+ )
1082
+
1083
+ changed = normalize_files(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run)
1084
+ print(f"normalize_changed={changed}")
1085
+
1086
+ baseline_changed = 0
1087
+ if ensure_agents(workspace=workspace, dry_run=args.dry_run):
1088
+ baseline_changed += 1
1089
+ print(f"upsert: {workspace / 'AGENTS.md'}")
1090
+ if enforce_config(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run):
1091
+ baseline_changed += 1
1092
+ print(f"upsert: {codex_home / 'config.toml'}")
1093
+ if ensure_bridge_skill(codex_home=codex_home, dry_run=args.dry_run):
1094
+ baseline_changed += 1
1095
+ print(f"upsert: {codex_home / 'skills' / 'claudekit-command-bridge'}")
1096
+ print(f"baseline_changed={baseline_changed}")
1097
+
1098
+ prompt_stats = export_prompts(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run)
1099
+ print(
1100
+ f"prompts: added={prompt_stats['added']} updated={prompt_stats['updated']} "
1101
+ f"skipped={prompt_stats['skipped']} removed={prompt_stats['removed']} "
1102
+ f"collisions={prompt_stats['collisions']} total_generated={prompt_stats['total_generated']}"
1103
+ )
1104
+
1105
+ bootstrap_stats = None
1106
+ if not args.skip_bootstrap:
1107
+ bootstrap_stats = bootstrap_deps(
1108
+ codex_home=codex_home,
1109
+ include_mcp=args.include_mcp,
1110
+ include_test_deps=args.include_test_deps,
1111
+ dry_run=args.dry_run,
1112
+ )
1113
+ print(
1114
+ f"bootstrap: python_ok={bootstrap_stats['python_ok']} python_fail={bootstrap_stats['python_fail']} "
1115
+ f"node_ok={bootstrap_stats['node_ok']} node_fail={bootstrap_stats['node_fail']}"
1116
+ )
1117
+ if (bootstrap_stats["python_fail"] or bootstrap_stats["node_fail"]) and not args.dry_run:
1118
+ raise SyncError("Dependency bootstrap reported failures")
1119
+
1120
+ verify_stats = None
1121
+ if not args.skip_verify:
1122
+ verify_stats = verify_runtime(codex_home=codex_home, dry_run=args.dry_run)
1123
+ print(f"verify: {verify_stats}")
1124
+
1125
+ summary = {
1126
+ "zip": str(zip_path),
1127
+ "codex_home": str(codex_home),
1128
+ "workspace": str(workspace),
1129
+ "dry_run": args.dry_run,
1130
+ "include_mcp": args.include_mcp,
1131
+ "include_hooks": args.include_hooks,
1132
+ "assets": assets_stats,
1133
+ "skills": skills_stats,
1134
+ "normalize_changed": changed,
1135
+ "baseline_changed": baseline_changed,
1136
+ "prompts": prompt_stats,
1137
+ "bootstrap": bootstrap_stats,
1138
+ "verify": verify_stats,
1139
+ }
1140
+ print_summary(summary)
1141
+ print("done: claudekit all-in-one sync completed")
1142
+ return 0
1143
+
1144
+
1145
+ if __name__ == "__main__":
1146
+ try:
1147
+ raise SystemExit(main())
1148
+ except SyncError as exc:
1149
+ eprint(f"error: {exc}")
1150
+ raise SystemExit(2)