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.
- package/AGENTS.md +45 -0
- package/README.md +131 -0
- package/bin/ck-codex-sync +12 -0
- package/bin/ck-codex-sync.js +9 -0
- package/docs/code-standards.md +62 -0
- package/docs/codebase-summary.md +83 -0
- package/docs/codex-vs-claude-agents.md +74 -0
- package/docs/installation-guide.md +64 -0
- package/docs/project-overview-pdr.md +44 -0
- package/docs/project-roadmap.md +51 -0
- package/docs/system-architecture.md +106 -0
- package/package.json +16 -0
- package/plans/260222-2051-claudekit-codex-community-sync/phase-01-productization.md +36 -0
- package/plans/260222-2051-claudekit-codex-community-sync/phase-02-core-refactor.md +32 -0
- package/plans/260222-2051-claudekit-codex-community-sync/phase-03-agent-transpiler.md +33 -0
- package/plans/260222-2051-claudekit-codex-community-sync/phase-04-parity-harness.md +43 -0
- package/plans/260222-2051-claudekit-codex-community-sync/phase-05-distribution-npm.md +35 -0
- package/plans/260222-2051-claudekit-codex-community-sync/phase-06-git-clone-docs.md +28 -0
- package/plans/260222-2051-claudekit-codex-community-sync/phase-07-qa-release.md +35 -0
- package/plans/260222-2051-claudekit-codex-community-sync/plan.md +99 -0
- package/plans/260223-0951-refactor-and-upgrade/phase-01-project-structure.md +79 -0
- package/plans/260223-0951-refactor-and-upgrade/phase-02-extract-templates.md +36 -0
- package/plans/260223-0951-refactor-and-upgrade/phase-03-modularize-python.md +107 -0
- package/plans/260223-0951-refactor-and-upgrade/phase-04-live-source-detection.md +76 -0
- package/plans/260223-0951-refactor-and-upgrade/phase-05-agent-toml-config.md +88 -0
- package/plans/260223-0951-refactor-and-upgrade/phase-06-backup-registry.md +58 -0
- package/plans/260223-0951-refactor-and-upgrade/phase-07-tests-docs-push.md +54 -0
- package/plans/260223-0951-refactor-and-upgrade/plan.md +72 -0
- package/reports/brainstorm-260222-2051-claudekit-codex-community-sync.md +113 -0
- package/scripts/bootstrap-claudekit-skill-scripts.sh +150 -0
- package/scripts/claudekit-sync-all.py +1150 -0
- package/scripts/export-claudekit-prompts.sh +221 -0
- package/scripts/normalize-claudekit-for-codex.sh +261 -0
- package/src/claudekit_codex_sync/__init__.py +0 -0
- package/src/claudekit_codex_sync/asset_sync_dir.py +125 -0
- package/src/claudekit_codex_sync/asset_sync_zip.py +140 -0
- package/src/claudekit_codex_sync/bridge_generator.py +33 -0
- package/src/claudekit_codex_sync/cli.py +199 -0
- package/src/claudekit_codex_sync/config_enforcer.py +140 -0
- package/src/claudekit_codex_sync/constants.py +104 -0
- package/src/claudekit_codex_sync/dep_bootstrapper.py +73 -0
- package/src/claudekit_codex_sync/path_normalizer.py +248 -0
- package/src/claudekit_codex_sync/prompt_exporter.py +89 -0
- package/src/claudekit_codex_sync/runtime_verifier.py +32 -0
- package/src/claudekit_codex_sync/source_resolver.py +78 -0
- package/src/claudekit_codex_sync/sync_registry.py +77 -0
- package/src/claudekit_codex_sync/utils.py +130 -0
- package/templates/agents-md.md +45 -0
- package/templates/bridge-docs-init.sh +25 -0
- package/templates/bridge-project-status.sh +49 -0
- package/templates/bridge-resolve-command.py +52 -0
- package/templates/bridge-skill.md +63 -0
- package/templates/command-map.md +44 -0
- package/tests/__init__.py +1 -0
- package/tests/test_config_enforcer.py +44 -0
- 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)
|