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,140 @@
1
+ """Asset and skill synchronization from zip files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import zipfile
7
+ from pathlib import Path
8
+ from typing import Dict, List, Tuple
9
+
10
+ from .constants import (
11
+ ASSET_DIRS,
12
+ ASSET_FILES,
13
+ ASSET_MANIFEST,
14
+ CONFLICT_SKILLS,
15
+ EXCLUDED_SKILLS_ALWAYS,
16
+ MCP_SKILLS,
17
+ )
18
+ from .source_resolver import collect_skill_entries, zip_mode
19
+ from .utils import load_manifest, save_manifest, write_bytes_if_changed
20
+
21
+
22
+ def sync_assets(
23
+ zf: zipfile.ZipFile,
24
+ *,
25
+ codex_home: Path,
26
+ include_hooks: bool,
27
+ dry_run: bool,
28
+ ) -> Dict[str, int]:
29
+ """Sync non-skill assets from zip to codex_home/claudekit."""
30
+ claudekit_dir = codex_home / "claudekit"
31
+ manifest_path = claudekit_dir / ASSET_MANIFEST
32
+ old_manifest = load_manifest(manifest_path)
33
+
34
+ selected: List[Tuple[str, str]] = []
35
+ for name in zf.namelist():
36
+ if name.endswith("/") or not name.startswith(".claude/"):
37
+ continue
38
+ rel = name[len(".claude/") :]
39
+ first = rel.split("/", 1)[0]
40
+ if first == "hooks" and include_hooks:
41
+ selected.append((name, rel))
42
+ continue
43
+ if first in ASSET_DIRS or rel in ASSET_FILES:
44
+ selected.append((name, rel))
45
+
46
+ new_manifest = {rel for _, rel in selected}
47
+ added = updated = removed = 0
48
+
49
+ for rel in sorted(old_manifest - new_manifest):
50
+ target = claudekit_dir / rel
51
+ if target.exists():
52
+ removed += 1
53
+ print(f"remove: {rel}")
54
+ if not dry_run:
55
+ target.unlink()
56
+
57
+ for zip_name, rel in sorted(selected, key=lambda x: x[1]):
58
+ info = zf.getinfo(zip_name)
59
+ data = zf.read(zip_name)
60
+ dst = claudekit_dir / rel
61
+ changed, is_added = write_bytes_if_changed(dst, data, mode=zip_mode(info), dry_run=dry_run)
62
+ if changed:
63
+ if is_added:
64
+ added += 1
65
+ print(f"add: {rel}")
66
+ else:
67
+ updated += 1
68
+ print(f"update: {rel}")
69
+
70
+ if not dry_run:
71
+ claudekit_dir.mkdir(parents=True, exist_ok=True)
72
+ save_manifest(manifest_path, new_manifest, dry_run=dry_run)
73
+
74
+ if not dry_run:
75
+ for d in sorted(claudekit_dir.rglob("*"), reverse=True):
76
+ if d.is_dir():
77
+ try:
78
+ d.rmdir()
79
+ except OSError:
80
+ pass
81
+
82
+ return {"added": added, "updated": updated, "removed": removed, "managed_files": len(new_manifest)}
83
+
84
+
85
+ def sync_skills(
86
+ zf: zipfile.ZipFile,
87
+ *,
88
+ codex_home: Path,
89
+ include_mcp: bool,
90
+ include_conflicts: bool,
91
+ dry_run: bool,
92
+ ) -> Dict[str, int]:
93
+ """Sync skills from zip to codex_home/skills."""
94
+ skills_dir = codex_home / "skills"
95
+ skill_entries = collect_skill_entries(zf)
96
+ added = updated = skipped = 0
97
+
98
+ for skill in sorted(skill_entries):
99
+ if skill in EXCLUDED_SKILLS_ALWAYS:
100
+ skipped += 1
101
+ print(f"skip: {skill}")
102
+ continue
103
+ if not include_mcp and skill in MCP_SKILLS:
104
+ skipped += 1
105
+ print(f"skip: {skill}")
106
+ continue
107
+ if skill in CONFLICT_SKILLS:
108
+ skipped += 1
109
+ print(f"skip: {skill}")
110
+ continue
111
+ if not include_conflicts and (skills_dir / ".system" / skill).exists():
112
+ skipped += 1
113
+ print(f"skip: {skill}")
114
+ continue
115
+
116
+ dst_skill_dir = skills_dir / skill
117
+ exists = dst_skill_dir.exists()
118
+ if exists:
119
+ updated += 1
120
+ print(f"update: {skill}")
121
+ else:
122
+ added += 1
123
+ print(f"add: {skill}")
124
+
125
+ if dry_run:
126
+ continue
127
+
128
+ if exists:
129
+ shutil.rmtree(dst_skill_dir)
130
+ dst_skill_dir.mkdir(parents=True, exist_ok=True)
131
+
132
+ for zip_name, inner in sorted(skill_entries[skill], key=lambda x: x[1]):
133
+ info = zf.getinfo(zip_name)
134
+ data = zf.read(zip_name)
135
+ dst = dst_skill_dir / inner
136
+ write_bytes_if_changed(dst, data, mode=zip_mode(info), dry_run=False)
137
+
138
+ skills_dir.mkdir(parents=True, exist_ok=True)
139
+ total_skills = len(list(skills_dir.rglob("SKILL.md")))
140
+ return {"added": added, "updated": updated, "skipped": skipped, "total_skills": total_skills}
@@ -0,0 +1,33 @@
1
+ """Bridge skill generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .utils import load_template, write_text_if_changed
8
+
9
+
10
+ def ensure_bridge_skill(*, codex_home: Path, dry_run: bool) -> bool:
11
+ """Ensure claudekit-command-bridge skill exists."""
12
+ bridge_dir = codex_home / "skills" / "claudekit-command-bridge"
13
+ scripts_dir = bridge_dir / "scripts"
14
+ if not dry_run:
15
+ scripts_dir.mkdir(parents=True, exist_ok=True)
16
+ changed = False
17
+
18
+ skill_md = load_template("bridge-skill.md")
19
+ resolve_script = load_template("bridge-resolve-command.py")
20
+ docs_init = load_template("bridge-docs-init.sh")
21
+ project_status = load_template("bridge-project-status.sh")
22
+
23
+ changed |= write_text_if_changed(bridge_dir / "SKILL.md", skill_md, dry_run=dry_run)
24
+ changed |= write_text_if_changed(
25
+ scripts_dir / "resolve-command.py", resolve_script, executable=True, dry_run=dry_run
26
+ )
27
+ changed |= write_text_if_changed(
28
+ scripts_dir / "docs-init.sh", docs_init, executable=True, dry_run=dry_run
29
+ )
30
+ changed |= write_text_if_changed(
31
+ scripts_dir / "project-status.sh", project_status, executable=True, dry_run=dry_run
32
+ )
33
+ return changed
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """All-in-one ClaudeKit -> Codex sync CLI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import zipfile
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ from .asset_sync_dir import sync_assets_from_dir, sync_skills_from_dir
14
+ from .asset_sync_zip import sync_assets, sync_skills
15
+ from .bridge_generator import ensure_bridge_skill
16
+ from .config_enforcer import ensure_agents, enforce_config, enforce_multi_agent_flag
17
+ from .dep_bootstrapper import bootstrap_deps
18
+ from .path_normalizer import normalize_agent_tomls, normalize_files
19
+ from .prompt_exporter import export_prompts
20
+ from .runtime_verifier import verify_runtime
21
+ from .source_resolver import detect_claude_source, find_latest_zip, validate_source
22
+ from .sync_registry import load_registry, save_registry
23
+ from .utils import SyncError, eprint
24
+
25
+
26
+ def print_summary(summary: Dict[str, Any]) -> None:
27
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
28
+
29
+ def parse_args() -> argparse.Namespace:
30
+ p = argparse.ArgumentParser(
31
+ description="All-in-one ClaudeKit -> Codex sync script."
32
+ )
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
+ return p.parse_args()
48
+
49
+
50
+ def main() -> int:
51
+ 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
+
56
+ # Load registry for backup/respect-edits
57
+ registry = load_registry(codex_home)
58
+
59
+ source_mode = args.source_mode
60
+ use_live = source_mode == "live" or (source_mode == "auto" and args.source_dir)
61
+
62
+ if use_live:
63
+ source = args.source_dir or detect_claude_source()
64
+ validation = validate_source(source)
65
+ print(f"source: {source} (live)")
66
+ print(f"validation: {validation}")
67
+ registry["sourceDir"] = str(source)
68
+ else:
69
+ zip_path = find_latest_zip(args.zip_path)
70
+ print(f"zip: {zip_path}")
71
+ registry["sourceDir"] = None
72
+
73
+ print(f"codex_home: {codex_home}")
74
+ print(f"workspace: {workspace}")
75
+ print(
76
+ f"include_mcp={args.include_mcp} include_hooks={args.include_hooks} "
77
+ f"dry_run={args.dry_run} respect_edits={args.respect_edits}"
78
+ )
79
+
80
+ codex_home.mkdir(parents=True, exist_ok=True)
81
+
82
+ if use_live:
83
+ assets_stats = sync_assets_from_dir(
84
+ source, codex_home=codex_home, include_hooks=args.include_hooks, dry_run=args.dry_run
85
+ )
86
+ skills_stats = sync_skills_from_dir(
87
+ source,
88
+ codex_home=codex_home,
89
+ include_mcp=args.include_mcp,
90
+ include_conflicts=args.include_conflicts,
91
+ dry_run=args.dry_run,
92
+ )
93
+ else:
94
+ with zipfile.ZipFile(zip_path) as zf: # type: ignore
95
+ assets_stats = sync_assets(
96
+ zf, codex_home=codex_home, include_hooks=args.include_hooks, dry_run=args.dry_run
97
+ )
98
+ skills_stats = sync_skills(
99
+ zf,
100
+ codex_home=codex_home,
101
+ include_mcp=args.include_mcp,
102
+ include_conflicts=args.include_conflicts,
103
+ dry_run=args.dry_run,
104
+ )
105
+
106
+ print(
107
+ f"assets: added={assets_stats['added']} updated={assets_stats['updated']} "
108
+ f"removed={assets_stats['removed']}"
109
+ )
110
+ print(
111
+ f"skills: added={skills_stats['added']} updated={skills_stats['updated']} "
112
+ f"skipped={skills_stats['skipped']}"
113
+ )
114
+
115
+ changed = normalize_files(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run)
116
+ print(f"normalize_changed={changed}")
117
+
118
+ # Agent TOML normalization
119
+ agent_toml_changed = 0
120
+ if not args.skip_agent_toml:
121
+ agent_toml_changed = normalize_agent_tomls(codex_home=codex_home, dry_run=args.dry_run)
122
+ print(f"agent_toml_changed={agent_toml_changed}")
123
+
124
+ baseline_changed = 0
125
+ if ensure_agents(workspace=workspace, dry_run=args.dry_run):
126
+ baseline_changed += 1
127
+ print(f"upsert: {workspace / 'AGENTS.md'}")
128
+ if enforce_config(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run):
129
+ baseline_changed += 1
130
+ print(f"upsert: {codex_home / 'config.toml'}")
131
+ if ensure_bridge_skill(codex_home=codex_home, dry_run=args.dry_run):
132
+ baseline_changed += 1
133
+ print(f"upsert: {codex_home / 'skills' / 'claudekit-command-bridge'}")
134
+
135
+ # Enable multi_agent flag
136
+ config_path = codex_home / "config.toml"
137
+ if enforce_multi_agent_flag(config_path, dry_run=args.dry_run):
138
+ print(f"upsert: multi_agent = true in {config_path}")
139
+
140
+ print(f"baseline_changed={baseline_changed}")
141
+
142
+ prompt_stats = export_prompts(codex_home=codex_home, include_mcp=args.include_mcp, dry_run=args.dry_run)
143
+ print(
144
+ f"prompts: added={prompt_stats['added']} updated={prompt_stats['updated']} "
145
+ f"total_generated={prompt_stats['total_generated']}"
146
+ )
147
+
148
+ bootstrap_stats = None
149
+ if not args.skip_bootstrap:
150
+ bootstrap_stats = bootstrap_deps(
151
+ codex_home=codex_home,
152
+ include_mcp=args.include_mcp,
153
+ include_test_deps=args.include_test_deps,
154
+ dry_run=args.dry_run,
155
+ )
156
+ print(
157
+ f"bootstrap: python_ok={bootstrap_stats['python_ok']} "
158
+ f"python_fail={bootstrap_stats['python_fail']}"
159
+ )
160
+ if (bootstrap_stats["python_fail"] or bootstrap_stats["node_fail"]) and not args.dry_run:
161
+ raise SyncError("Dependency bootstrap reported failures")
162
+
163
+ verify_stats = None
164
+ if not args.skip_verify:
165
+ verify_stats = verify_runtime(codex_home=codex_home, dry_run=args.dry_run)
166
+ print(f"verify: {verify_stats}")
167
+
168
+ # Save registry
169
+ if not args.dry_run:
170
+ save_registry(codex_home, registry)
171
+
172
+ summary = {
173
+ "source": str(source) if use_live else str(zip_path),
174
+ "source_mode": "live" if use_live else "zip",
175
+ "codex_home": str(codex_home),
176
+ "workspace": str(workspace),
177
+ "dry_run": args.dry_run,
178
+ "include_mcp": args.include_mcp,
179
+ "include_hooks": args.include_hooks,
180
+ "assets": assets_stats,
181
+ "skills": skills_stats,
182
+ "normalize_changed": changed,
183
+ "agent_toml_changed": agent_toml_changed,
184
+ "baseline_changed": baseline_changed,
185
+ "prompts": prompt_stats,
186
+ "bootstrap": bootstrap_stats,
187
+ "verify": verify_stats,
188
+ }
189
+ print_summary(summary)
190
+ print("done: claudekit all-in-one sync completed")
191
+ return 0
192
+
193
+
194
+ if __name__ == "__main__":
195
+ try:
196
+ raise SystemExit(main())
197
+ except SyncError as exc:
198
+ eprint(f"error: {exc}")
199
+ raise SystemExit(2)
@@ -0,0 +1,140 @@
1
+ """Config enforcement for Codex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import List
8
+
9
+
10
+ def ensure_agents(*, workspace: Path, dry_run: bool) -> bool:
11
+ """Ensure AGENTS.md exists in workspace."""
12
+ from .utils import load_template, write_text_if_changed
13
+ target = workspace / "AGENTS.md"
14
+ template = load_template("agents-md.md")
15
+ return write_text_if_changed(target, template, dry_run=dry_run)
16
+
17
+
18
+ def enforce_config(*, codex_home: Path, include_mcp: bool, dry_run: bool) -> bool:
19
+ """Enforce Codex config defaults."""
20
+ config = codex_home / "config.toml"
21
+ if config.exists():
22
+ text = config.read_text(encoding="utf-8")
23
+ else:
24
+ text = ""
25
+ orig = text
26
+
27
+ if re.search(r"^project_doc_max_bytes\s*=", text, flags=re.M):
28
+ text = re.sub(r"^project_doc_max_bytes\s*=.*$", "project_doc_max_bytes = 65536", text, flags=re.M)
29
+ else:
30
+ text = (text.rstrip("\n") + "\nproject_doc_max_bytes = 65536\n").lstrip("\n")
31
+
32
+ fallback_line = 'project_doc_fallback_filenames = ["AGENTS.md", "CLAUDE.md", "AGENTS.override.md"]'
33
+ if re.search(r"^project_doc_fallback_filenames\s*=", text, flags=re.M):
34
+ text = re.sub(r"^project_doc_fallback_filenames\s*=.*$", fallback_line, text, flags=re.M)
35
+ else:
36
+ text = text.rstrip("\n") + "\n" + fallback_line + "\n"
37
+
38
+ mcp_management_path = str((codex_home / "skills" / "mcp-management").resolve())
39
+ mcp_builder_path = str((codex_home / "skills" / "mcp-builder").resolve())
40
+ mcp_enabled = "true" if include_mcp else "false"
41
+
42
+ pattern = re.compile(r"\n\[\[skills\.config\]\]\n(?:[^\n]*\n)*?(?=\n\[\[skills\.config\]\]|\Z)", re.M)
43
+ blocks = pattern.findall("\n" + text)
44
+ kept: List[str] = []
45
+ for block in blocks:
46
+ if f'path = "{mcp_management_path}"' in block:
47
+ continue
48
+ if f'path = "{mcp_builder_path}"' in block:
49
+ continue
50
+ kept.append(block.rstrip("\n"))
51
+
52
+ base = pattern.sub("", "\n" + text).lstrip("\n").rstrip("\n")
53
+ for block in kept:
54
+ if block:
55
+ base += "\n\n" + block
56
+
57
+ base += f'\n\n[[skills.config]]\npath = "{mcp_management_path}"\nenabled = {mcp_enabled}\n'
58
+ base += f'\n[[skills.config]]\npath = "{mcp_builder_path}"\nenabled = {mcp_enabled}\n'
59
+
60
+ if base == orig:
61
+ return False
62
+ if not dry_run:
63
+ config.parent.mkdir(parents=True, exist_ok=True)
64
+ config.write_text(base, encoding="utf-8")
65
+ return True
66
+
67
+
68
+ def enforce_multi_agent_flag(config_path: Path, dry_run: bool) -> bool:
69
+ """Ensure multi_agent and child_agents_md flags are set in config."""
70
+ text = config_path.read_text() if config_path.exists() else ""
71
+ orig = text
72
+
73
+ if "[features]" in text:
74
+ if "multi_agent" not in text:
75
+ text = text.replace("[features]", "[features]\nmulti_agent = true")
76
+ if "child_agents_md" not in text:
77
+ text = text.replace("[features]", "[features]\nchild_agents_md = true")
78
+ else:
79
+ text += "\n[features]\nmulti_agent = true\nchild_agents_md = true\n"
80
+
81
+ if text != orig and not dry_run:
82
+ config_path.write_text(text)
83
+ return text != orig
84
+
85
+
86
+ def _extract_description(toml_text: str) -> str:
87
+ """Extract first meaningful sentence from developer_instructions."""
88
+ match = re.search(
89
+ r'developer_instructions\s*=\s*"""(.*?)"""',
90
+ toml_text,
91
+ re.DOTALL,
92
+ )
93
+ if not match:
94
+ return ""
95
+ body = match.group(1).strip()
96
+ for line in body.splitlines():
97
+ line = line.strip()
98
+ if line.startswith("#") or not line:
99
+ continue
100
+ # Strip markdown bold
101
+ line = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
102
+ # Take first sentence
103
+ dot = line.find(". ")
104
+ if dot > 0:
105
+ return line[: dot + 1]
106
+ return line[:120]
107
+ return ""
108
+
109
+
110
+ def register_agents(*, codex_home: Path, dry_run: bool) -> int:
111
+ """Register agent TOMLs as [agents.*] roles in config.toml."""
112
+ agents_dir = codex_home / "agents"
113
+ config_path = codex_home / "config.toml"
114
+ if not agents_dir.exists():
115
+ return 0
116
+
117
+ text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
118
+ added = 0
119
+
120
+ for toml_file in sorted(agents_dir.glob("*.toml")):
121
+ slug = toml_file.stem
122
+ section_header = f"[agents.{slug}]"
123
+ if section_header in text:
124
+ continue
125
+
126
+ content = toml_file.read_text(encoding="utf-8")
127
+ desc = _extract_description(content) or f"{slug.replace('_', ' ').title()} agent"
128
+ # Escape quotes in description
129
+ desc = desc.replace('"', '\\"')
130
+
131
+ text += (
132
+ f"\n{section_header}\n"
133
+ f'description = "{desc}"\n'
134
+ f'config_file = "agents/{toml_file.name}"\n'
135
+ )
136
+ added += 1
137
+
138
+ if added > 0 and not dry_run:
139
+ config_path.write_text(text, encoding="utf-8")
140
+ return added
@@ -0,0 +1,104 @@
1
+ """Constants for path normalization and sync operations."""
2
+
3
+ from typing import List, Set, Tuple
4
+
5
+ ASSET_DIRS = {"agents", "commands", "output-styles", "rules", "scripts"}
6
+ ASSET_FILES = {
7
+ "CLAUDE.md",
8
+ ".ck.json",
9
+ ".ckignore",
10
+ ".env.example",
11
+ ".mcp.json.example",
12
+ "settings.json",
13
+ "metadata.json",
14
+ "statusline.cjs",
15
+ "statusline.sh",
16
+ "statusline.ps1",
17
+ }
18
+ ASSET_MANIFEST = ".sync-manifest-assets.txt"
19
+ PROMPT_MANIFEST = ".claudekit-generated-prompts.txt"
20
+ REGISTRY_FILE = ".claudekit-sync-registry.json"
21
+
22
+ EXCLUDED_SKILLS_ALWAYS: Set[str] = {"template-skill"}
23
+ MCP_SKILLS: Set[str] = {"mcp-builder", "mcp-management"}
24
+ CONFLICT_SKILLS: Set[str] = {"skill-creator"}
25
+
26
+ SKILL_MD_REPLACEMENTS: List[Tuple[str, str]] = [
27
+ ("$HOME/.claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
28
+ ("$HOME/.claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
29
+ ("$HOME/.claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
30
+ ("$HOME/.claude/", "${CODEX_HOME:-$HOME/.codex}/"),
31
+ ("./.claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
32
+ (".claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
33
+ ("./.claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
34
+ (".claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
35
+ ("./.claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
36
+ (".claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
37
+ ("~/.claude/.ck.json", "~/.codex/claudekit/.ck.json"),
38
+ ("./.claude/.ck.json", "~/.codex/claudekit/.ck.json"),
39
+ (".claude/.ck.json", "~/.codex/claudekit/.ck.json"),
40
+ ("~/.claude/", "~/.codex/"),
41
+ ("./.claude/", "./.codex/"),
42
+ ("<project>/.claude/", "<project>/.codex/"),
43
+ (".claude/", ".codex/"),
44
+ ("`.claude`", "`.codex`"),
45
+ ("$HOME/${CODEX_HOME:-$HOME/.codex}/", "${CODEX_HOME:-$HOME/.codex}/"),
46
+ ]
47
+
48
+ PROMPT_REPLACEMENTS: List[Tuple[str, str]] = [
49
+ ("$HOME/.claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
50
+ ("$HOME/.claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
51
+ ("$HOME/.claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
52
+ ("$HOME/.claude/", "${CODEX_HOME:-$HOME/.codex}/"),
53
+ ("./.claude/skills/", "~/.codex/skills/"),
54
+ (".claude/skills/", "~/.codex/skills/"),
55
+ ("./.claude/scripts/", "~/.codex/claudekit/scripts/"),
56
+ (".claude/scripts/", "~/.codex/claudekit/scripts/"),
57
+ ("./.claude/rules/", "~/.codex/claudekit/rules/"),
58
+ (".claude/rules/", "~/.codex/claudekit/rules/"),
59
+ ("~/.claude/.ck.json", "~/.codex/claudekit/.ck.json"),
60
+ ("./.claude/.ck.json", "~/.codex/claudekit/.ck.json"),
61
+ (".claude/.ck.json", "~/.codex/claudekit/.ck.json"),
62
+ ("$HOME/${CODEX_HOME:-$HOME/.codex}/", "${CODEX_HOME:-$HOME/.codex}/"),
63
+ ]
64
+
65
+ AGENT_TOML_REPLACEMENTS: List[Tuple[str, str]] = [
66
+ ("$HOME/.claude/skills/", "${CODEX_HOME:-$HOME/.codex}/skills/"),
67
+ ("$HOME/.claude/scripts/", "${CODEX_HOME:-$HOME/.codex}/claudekit/scripts/"),
68
+ ("$HOME/.claude/rules/", "${CODEX_HOME:-$HOME/.codex}/claudekit/rules/"),
69
+ ("$HOME/.claude/.ck.json", "${CODEX_HOME:-$HOME/.codex}/claudekit/.ck.json"),
70
+ ("$HOME/.claude/.mcp.json", "${CODEX_HOME:-$HOME/.codex}/claudekit/.mcp.json"),
71
+ ("$HOME/.claude/", "${CODEX_HOME:-$HOME/.codex}/"),
72
+ ("~/.claude/", "~/.codex/"),
73
+ ]
74
+
75
+ CLAUDE_SYNTAX_ADAPTATIONS: List[Tuple[str, str]] = [
76
+ ("Task(Explore)", "the explore agent"),
77
+ ("Task(researcher)", "the researcher agent"),
78
+ ("Task(", "delegate to "),
79
+ ("$HOME/.claude/skills/*", "${CODEX_HOME:-$HOME/.codex}/skills/*"),
80
+ ]
81
+
82
+ # Claude model → Codex model mapping (per developers.openai.com/codex/multi-agent)
83
+ CLAUDE_TO_CODEX_MODELS: dict[str, str] = {
84
+ "opus": "gpt-5.3-codex",
85
+ "sonnet": "gpt-5.3-codex",
86
+ "haiku": "gpt-5.3-codex-spark",
87
+ "inherit": "",
88
+ }
89
+
90
+ # Reasoning effort per Claude model tier
91
+ CLAUDE_MODEL_REASONING_EFFORT: dict[str, str] = {
92
+ "opus": "xhigh",
93
+ "sonnet": "high",
94
+ "haiku": "medium",
95
+ }
96
+
97
+ # Agents that should be read-only (no file writes needed)
98
+ READ_ONLY_AGENT_ROLES: Set[str] = {
99
+ "brainstormer",
100
+ "code_reviewer",
101
+ "researcher",
102
+ "project_manager",
103
+ "journal_writer",
104
+ }