diff --git a/agents/skills/README.md b/agents/skills/README.md index 350f1ab..9567c7c 100644 --- a/agents/skills/README.md +++ b/agents/skills/README.md @@ -3,6 +3,7 @@ Codex skills stored for installation to `~/.codex/skills` via `./install.sh --skills` or `./install.sh --agents`. ## Included +- `audit-ai-code` - Audit AI-shaped backend/general code and apply safe cleanup fixes. - `audit-ai-frontend` - Audit AI-looking frontend UI and apply targeted fixes. - `audit-ai-writing` - Audit AI-writing residue, citation failures, and cleanup rewrites. - `gh-address-comments` - Address GitHub PR review comments on the current branch. diff --git a/agents/skills/audit-ai-code/SKILL.md b/agents/skills/audit-ai-code/SKILL.md new file mode 100644 index 0000000..0880f87 --- /dev/null +++ b/agents/skills/audit-ai-code/SKILL.md @@ -0,0 +1,75 @@ +--- +name: audit-ai-code +description: Audit AI-generated or AI-shaped backend/general code diffs for duplicate helpers, over-defensive control flow, broad exception wrappers, speculative scaffolding, comment/docstring boilerplate, local style drift, hallucinated APIs/dependencies, fixture-shaped test hacks, and obvious safety/performance gaps. Use when reviewing or safely cleaning up Python, TypeScript, or other implementation code after a feature, bugfix, or prototype pass. +--- + +# AI Code Audit + +## Use + +Audit or repair implementation code that reads generically AI-generated, while preserving behavior, public APIs, and tests unless the user explicitly asks for a refactor. + +Review in this order: + +1. Find the target scope. + - Prefer `git diff --check` and `git diff --stat` first. + - Inspect the current diff for touched files; if there is no git diff, fall back to recently modified files. + - Ask one narrow question if scope is genuinely ambiguous. + +2. Collapse duplicate helpers and shadow APIs. + - Find helpers or wrappers that do the same job with slightly different names, signatures, or one-off branches. + - Prefer one canonical helper with a narrow, clear API. + - Replace hand-rolled parsing, path, date/time, and string logic with existing project utilities when they already exist. + - Verify any newly introduced helper, method, import, or package is real and canonical in this repo or dependency graph. + +3. Flatten defensive control flow and exception boundaries. + - Replace nested condition ladders with guard clauses and early exits. + - Consolidate checks that lead to the same result and hoist duplicate branch bodies. + - Remove stateful control flags when direct control flow is clearer. + - Delete broad exception wrappers that hide uncertainty, keep one clear handler around real boundary failures, and replace expected non-exceptional cases with explicit precondition checks. + +4. Remove generated-code residue. + - Delete speculative abstractions, factories, generic hooks, pass-through wrappers, broad options objects, dead branches, and placeholder fallbacks that have no concrete caller or product need. + - Remove comments/docstrings that restate obvious code; keep only non-obvious intent, invariants, and tradeoffs. + - Normalize naming, module boundaries, error style, and helper shape to match adjacent hand-written code. + - Treat fixture-shaped branches, magic constants, and deleted or weakened tests as a smell; encode the actual invariant instead. + +5. Check safety and runtime basics. + - Look for secrets in code/config, string-built queries or shell commands, path traversal, unsafe deserialization, SSRF-shaped fetches, missing server-side authorization, sensitive data in logs, swallowed exceptions, unchecked return values, missing outbound timeouts, and check-then-act races. + - Patch only high-confidence local fixes; report broader security or architecture changes as follow-up. + +6. Verify. + - Run the narrowest relevant tests, typechecks, and linters for touched files. + - Re-open the diff and confirm cleanup did not change intended behavior. + +For larger diffs, parallelize read-only review into up to four passes: reuse/shadow APIs, control-flow/exception boundaries, generated-code residue, and quality/safety/performance. Prefer a stronger model for ambiguous tradeoffs and a smaller model for narrow, easy-to-verify scans. + +## Output + +For each finding, include: + +- `Issue` +- `Evidence` +- `Class` (`P0`, `P1`, `P2`) +- `Why it matters / why it reads as generated` +- `Possible non-AI explanation` +- `Smallest fix` +- `Acceptance check` +- `Confidence` (`High`, `Medium`, `Low`) +- `File/line` + +Return only the top 5-8 findings for review-only asks and merge repeated symptoms under one root cause. + +For implementation asks, patch the code directly, then summarize what was simplified, what was intentionally left alone, what validation ran, and any follow-up risks. + +## Guardrails + +- Treat "AI-looking" as a quality smell, not a provenance claim. +- Prefer objective maintainability, correctness, and safety defects over style-only opinions. +- Do not widen APIs into mega-helpers, config bags, or boolean-flag mode switches just to reduce line count. +- Do not add speculative abstraction layers, broad framework wrappers, or one-off utility namespaces. +- Do not reformat unrelated files or chase broad style churn. + +## Resource + +- `references/sources.md`: source basis for code-smell, AI-generated-code, and security-review checks. diff --git a/agents/skills/audit-ai-code/agents/openai.yaml b/agents/skills/audit-ai-code/agents/openai.yaml new file mode 100644 index 0000000..654df2b --- /dev/null +++ b/agents/skills/audit-ai-code/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "AI Code Audit" + short_description: "Audit AI-shaped backend/code smells and apply safe fixes." + default_prompt: "Use $audit-ai-code to review my current diff for duplicate helpers, defensive branches, broad exception handlers, speculative wrappers, and fixture-shaped hacks." diff --git a/agents/skills/audit-ai-code/references/sources.md b/agents/skills/audit-ai-code/references/sources.md new file mode 100644 index 0000000..e7e1017 --- /dev/null +++ b/agents/skills/audit-ai-code/references/sources.md @@ -0,0 +1,10 @@ +# Source basis + +This skill is a quality and safety audit checklist, not proof of AI authorship. Use these references to ground the smell taxonomy and reviewer language. + +- Wikipedia on code smells: `https://en.wikipedia.org/wiki/Code_smell` +- Refactoring.Guru smell catalog and refactorings, especially Duplicate Code, Long Method, Long Parameter List, Comments, and Speculative Generality: `https://refactoring.guru/refactoring/smells` +- GitHub Copilot responsible-use guidance for code review, including security checks and AI-generated suggestion risk: `https://docs.github.com/en/copilot/responsible-use/code-review` +- LLM-generated code smell study: `https://arxiv.org/abs/2510.03029` +- Package hallucination risk in AI-generated code: `https://www.usenix.org/publications/loginonline/we-have-package-you-comprehensive-analysis-package-hallucinations-code` +- Hallucinated API behavior in code-generation systems: `https://arxiv.org/abs/2401.01701` diff --git a/agents/skills/audit-ai-frontend/SKILL.md b/agents/skills/audit-ai-frontend/SKILL.md index 9661ba5..317f208 100644 --- a/agents/skills/audit-ai-frontend/SKILL.md +++ b/agents/skills/audit-ai-frontend/SKILL.md @@ -5,63 +5,50 @@ description: Audit AI-generated or AI-looking frontend implementations, UI scree # AI Frontend Audit -## Use +Use this one-page scale to audit or repair frontend UI that looks generically AI-generated. Treat model/tool clues as weak priors; judge the shipped experience. -Audit or repair frontend UI that looks generically AI-generated, while preserving existing structure unless the user asks for a redesign. +## Scale -Review in this order: +`S0` Blockers: broken keyboard, labels, focus, contrast, touch targets, mobile layout, or missing loading/empty/error states. -1. Inspect code and UI together. - - Read components, CSS/theme tokens, and existing primitives first. - - If runnable, invoke `$playwright` or `$playwright-interactive` and follow that skill's `SKILL.md`; this skill decides what to inspect, not browser mechanics. - - If screenshot-only, review visuals but label implementation risks as `Inferred`. +`S1` Product truth: fake metrics, demo data, missing source/date labels, UI/API/schema drift, auth or tenancy assumptions, no retry or recovery. -2. Load only the reference you need. - - `references/patterns.md` for concrete AI-tell and code-smell fixes. - - `references/rubric.md` for broad UX/a11y/design audits. - - `references/workflows.md` for Playwright QA, reference-packet, and brief-lock loops. +`S2` Local fit: ignores the repo's component library, tokens, typography, density, adjacent screens, or established interaction states. -3. Preserve local system intent while removing accidental defaults. - - Keep copy/order/IA and known product tokens unless the user asks for a redesign. - - Keep a common-looking font/card/palette only if adjacent screens or documented tokens already use it; replace it when the style exists only in the generated screen. - - If references are missing, derive one explicit design contract from product domain + user job + existing primitives; do not fabricate named reference sites. +`S3` Task hierarchy: generic dashboard or landing-page structure, all panels equal weight, unclear primary action, weak IA. -4. Fix in this order. - - `P0`: keyboard, labels, contrast, touch targets, mobile overflow, missing loading/empty/error states. - - `P1`: generic SaaS layout, card overuse, icon-pill repetition, Inter/Roboto/system defaults, purple/indigo/cyan gradient/glass tropes, vague CTA/copy. - - `P2`: spacing rhythm, token consistency, one memorable visual rule, reduced-motion and state polish. +`S4` AI aesthetic defaults: Inter/system-only personality, purple/indigo/cyan gradients, glass/glow layers, rounded-card grids, Lucide icon pills, vague CTA/copy, overlong explanatory prose, repeated section shells. -5. Re-verify in browser after edits whenever possible. +`S5` Tool fingerprints: v0/shadcn registry shells, Claude artifact polish, Codex minimal-diff conservatism, Gemini explainer layouts, Lovable/Supabase app shells, Bolt/Replit fallback scaffolds, Figma layer residue. -## Output +`S6` Creative polish: fun styles, algorithmic art, theme packs, image assets, stickers, motion, or novelty that does not affect the core task. -For each finding, include: +## Do -- `Issue` -- `Evidence` -- `Class` (`P0`, `P1`, `P2`) -- `Why it matters / why it reads as generic` -- `Possible non-AI explanation` -- `Smallest fix` -- `Acceptance check` -- `Confidence` (`High`, `Medium`, `Low`) -- `File/line` when code is available +- Inspect code and UI together before proposing changes. +- Rank findings by the scale above; fix lower-numbered issues before style. +- Preserve local copy, IA, tokens, and component primitives unless they are the problem. +- Use installed icon packs or existing icon components by default; custom SVGs are only for bespoke product marks, diagrams, or assets the icon set cannot express. +- Use source/tool clues only to expand searches, for example `CardHeader`, `text-muted-foreground`, `lucide-react`, `supabase`, `VITE_`, fixed `left/top/width/height`, `features.map`, `bg-clip-text`. +- Replace generic polish with product-specific hierarchy: one primary action, one dominant data surface, concrete object/action copy, and realistic states. +- When a UI still feels AI-generated after visual cleanup, cut copy and change the information structure before changing colors or adding decoration. +- Verify runnable UIs in browser at desktop and mobile sizes, including keyboard/focus, long text, empty data, loading, errors, disabled states, and reduced motion. -Return only the top 5-8 findings and merge repeated symptoms under one root cause. End with one line: `If I had to change only one thing: ...` +## Don't -For implementation asks, patch the code directly, then summarize only the meaningful design changes and any remaining risk. +- Don't claim AI authorship from style, model fingerprints, or component choices. +- Don't prioritize fun skills, stickers, algorithmic art, theme packs, or dramatic motion above operability and product truth. +- Don't overcorrect generic UI with random ornaments, novelty fonts, noisy textures, or one-off visual chaos. +- Don't keep repeated card grids, bordered panels, or long prose blocks just because they are already implemented; collapse them into rows, matrices, labels, or plain text when the content is simple. +- Don't hand-roll inline SVG icons when the repo already has `lucide-react`, Heroicons, Font Awesome, Radix icons, Material icons, or another installed icon system. +- Don't replace a documented design system just because it uses common fonts, cards, or neutral tokens. +- Don't report inferred accessibility or code defects from screenshots as fact; mark them `Inferred`. +- Don't leave pretty demo states in place of real authorization, validation, empty, error, setup, or API contract behavior. -## Guardrails +## Output -- Treat "AI-looking" as a quality smell, not a provenance claim. -- Prefer objective defects over taste opinions. -- When auditing shadcn/ui projects, preserve semantic component usage and tokens. Use the `shadcn` skill if component APIs, registry install/update, or shadcn-specific composition rules are part of the fix. -- Avoid anti-slop overcorrection: no random ornaments, novelty fonts, or one-off visual chaos. -- Anchor each finding in code, screenshots, DOM/a11y snapshots, or browser behavior, and separate fact from inference. +For review-only asks, return the top 5-8 findings with `Issue`, `Evidence`, `Scale`, `Class`, `Smallest fix`, `Acceptance check`, `Confidence`, and `File/line`. Merge repeated symptoms under one root cause and end with `If I had to change only one thing: ...` -## Resource +For implementation asks, patch directly, then summarize the meaningful design changes and remaining risk. -- `references/patterns.md`: checklist of AI-frontend tells, code smells, and repair patterns. -- `references/rubric.md`: compact UX/a11y/design-quality rubric for broader audits. -- `references/workflows.md`: Playwright QA, reference-packet, and brief-lock loops; delegates browser mechanics to `$playwright` and `$playwright-interactive`. -- Use `$playwright` and `$playwright-interactive` directly for browser execution workflows. +Use `references/patterns.md`, `references/rubric.md`, `references/workflows.md`, and `references/sources.md` only when the one-page scale is not enough. diff --git a/agents/skills/audit-ai-frontend/references/patterns.md b/agents/skills/audit-ai-frontend/references/patterns.md index 5dd8643..ecb6790 100644 --- a/agents/skills/audit-ai-frontend/references/patterns.md +++ b/agents/skills/audit-ai-frontend/references/patterns.md @@ -216,6 +216,9 @@ Search for these framework-agnostic patterns before patching: - repeated hover scale transforms on every card or tile - repeated `Card` maps with the same `Icon + title + description` structure - repeated outline-icon imports used only as section decoration +- component-library demo shells such as `CardHeader`, `CardDescription`, `Badge`, `Tabs`, `DropdownMenu`, `text-muted-foreground`, or `lucide-react` +- full-stack builder scaffolding such as `Dashboard`, `Overview`, `Recent Activity`, `Settings`, `ProtectedRoute`, `useAuth`, `supabase`, `VITE_`, or fallback demo arrays +- design-to-code residue such as fixed `left/top/width/height`, layer-like asset names, or repeated exact pixel values - `outline: none`, `tabIndex={-1}`, clickable `div`/`span` - missing `aria-label`, `aria-describedby`, `alt`, or dialog titles - fixed desktop widths, fixed card grids, sticky sidebars, or wide tables with no mobile fallback @@ -226,6 +229,7 @@ If the project uses Tailwind, also search for these optional utility-class equiv - `font-sans`, `from-purple-*`, `to-indigo-*`, `bg-indigo-*`, `text-transparent bg-clip-text` - `rounded-2xl`, `rounded-3xl`, `shadow-xl`, `backdrop-blur-*`, `hover:scale-105` +- `text-muted-foreground`, `bg-card`, `border-border`, `bg-background`, `--radius`, `oklch` ## Minimal Repair Playbook diff --git a/agents/skills/audit-ai-frontend/references/sources.md b/agents/skills/audit-ai-frontend/references/sources.md new file mode 100644 index 0000000..f2b69bf --- /dev/null +++ b/agents/skills/audit-ai-frontend/references/sources.md @@ -0,0 +1,15 @@ +# Source basis + +This skill treats "AI-looking" UI as a quality pattern, not a provenance claim. Use these references to ground the visual-smell and UX checks. + +- Wikipedia on AI slop: `https://en.wikipedia.org/wiki/AI_slop` +- CrowdGenUI study on generated UI converging to generic solutions and missing task/user context: `https://arxiv.org/abs/2411.03477` +- Jidong Lab essay on why AI-generated UIs converge visually: `https://www.jidonglab.com/blog/why-every-ai-generated-ui-looks-the-same-and-how-to-escape-the-digital-sea-of-sameness` +- WCAG 2.2 for objective accessibility failures such as contrast, focus indicators, and touch target size: `https://www.w3.org/TR/WCAG22/` +- OpenAI model/release notes for current GPT/Codex coding behavior: `https://openai.com/research/index/release/` +- Anthropic Claude Sonnet 4.5 release notes for current coding/agentic/computer-use positioning: `https://www.anthropic.com/news/claude-sonnet-4-5` +- Google Gemini 3 in Search / AI Mode notes for generative UI, dynamic visual layouts, interactive tools, and simulations: `https://blog.google/products/search/gemini-3-search-ai-mode` +- Vercel AI Elements notes for shadcn/ui-based AI interface primitives: `https://vercel.com/changelog/introducing-ai-elements` +- shadcn/ui docs for Tailwind/CSS-variable component defaults used by many generated UIs: `https://ui.shadcn.com/docs` +- Lovable docs for Supabase-centered app-builder workflows: `https://docs.lovable.dev/` +- Bolt docs for browser full-stack app-builder workflows: `https://support.bolt.new/` diff --git a/agents/skills/audit-ai-frontend/references/workflows.md b/agents/skills/audit-ai-frontend/references/workflows.md index 56499c1..abe261b 100644 --- a/agents/skills/audit-ai-frontend/references/workflows.md +++ b/agents/skills/audit-ai-frontend/references/workflows.md @@ -10,7 +10,7 @@ Delegate browser operation details to the existing Playwright skills: - Use `$playwright` for one-shot CLI browser inspection, snapshots, screenshots, and trace capture. - Use `$playwright-interactive` for persistent browser sessions, repeated edit/reload loops, and deeper visual QA with a shared QA inventory. -- Open `${CODEX_HOME:-$HOME/.codex}/skills/playwright/SKILL.md` or `${CODEX_HOME:-$HOME/.codex}/skills/playwright-interactive/SKILL.md` before running browser commands. +- Open `/Users/jasonliu/.codex/skills/playwright/SKILL.md` or `/Users/jasonliu/.codex/skills/playwright-interactive/SKILL.md` before running browser commands. - Keep this workflow focused on what to inspect for AI-frontend quality, not how to operate Playwright primitives. 1. Open the page in a real browser using the `playwright` skill. diff --git a/agents/skills/audit-ai-writing/SKILL.md b/agents/skills/audit-ai-writing/SKILL.md index bae231c..7c66d4c 100644 --- a/agents/skills/audit-ai-writing/SKILL.md +++ b/agents/skills/audit-ai-writing/SKILL.md @@ -7,6 +7,8 @@ description: Reference-only checklist for AI-writing artifacts, citation failure ## Use +Audit or repair Markdown, docs, and pasted prose that may contain AI-writing residue, broken citations, or house-style drift. + Open `patterns.md` and review in this order: 1. Machine residue, broken markup, and broken citations. @@ -21,13 +23,14 @@ For each finding, include: - `Issue` - `Evidence` (exact snippet or line location) - `Class` (`P0`, `P1`, `P2`) -- `Why it matters / why it reads as generic` +- `Why it matters / why it reads as generated` - `Possible non-AI explanation` - `Smallest fix` +- `Acceptance check` - `Confidence` (`High`, `Medium`, `Low`) -- `File/line` when available +- `File/line` when a file is available -Return only the top 5-8 findings and merge repeated symptoms under one root cause. +Return only the top 5-8 findings and merge repeated symptoms under one root cause. For rewrite asks, patch the text directly and summarize only the meaningful cleanup. ## Guardrails @@ -38,4 +41,4 @@ Return only the top 5-8 findings and merge repeated symptoms under one root caus ## Resource -- `patterns.md`: compact artifact taxonomy, verification checks, and rewrite guidance. +- `patterns.md`: compact artifact taxonomy, verification checks, rewrite guidance, and source basis. diff --git a/agents/skills/audit-ai-writing/patterns.md b/agents/skills/audit-ai-writing/patterns.md index 4309ab0..f358ced 100644 --- a/agents/skills/audit-ai-writing/patterns.md +++ b/agents/skills/audit-ai-writing/patterns.md @@ -84,6 +84,26 @@ Fix: - Replace relative dates with absolute dates or explicit version ranges. - If roadmap timing is uncertain, scope the claim to the source date and avoid forward-looking promises. +## Rule: tighten docs wording without changing meaning + +- Synonym churn for the same concept: one feature, API, or object gets renamed repeatedly just to vary wording. +- Weak or padded verbs: `utilize`, `leverage`, "make use of", "is able to", "establish connectivity", "perform an update". +- Ambiguous task language: "the user should...", "one can...", or "you should..." where it is unclear whether the step is required, optional, or just a recommendation. +- Hidden actors and vague passive voice: "was implemented", "is performed", "was generated", "it was decided" when the sentence no longer says who did what. +- Note/sidebar clutter: stacked `Note:` blocks, prerequisites hidden in callouts, or parenthetical asides that interrupt step text. +- Culturally narrow idioms and cute metaphors: pop-culture jokes, "boom", "game-changer", mixed metaphors, or local references that reduce clarity for global readers. +- Meaning drift after "polish": rewritten text becomes more neutral, broader, or softer than the source claim and loses the original stance, constraint, or caveat. + +Fix: + +- Use one canonical term per concept unless the text is intentionally introducing a synonym. +- Replace padded verb phrases with one precise verb and use direct `you` + imperative for procedural docs when that matches house style. +- Replace ambiguous `should` with `must`, `can`, `might`, or "We recommend..." based on the actual requirement level. +- Rewrite passive sentences to actor + verb when the actor matters. +- Inline required prerequisites, keep only genuinely optional notes, and move long asides out of step sentences. +- Replace idioms and jokes with plain literal wording. +- Compare the edited sentence against the source or previous draft and restore any lost claim, boundary, or caveat. + ## Rule: normalize repetitive style clusters without over-claiming - Dense clusters of abstract "AI vocabulary": `pivotal`, `underscores`, `delve`, `interplay`, `intricate`, `tapestry`, `vibrant`, `landscape`, `showcase`, repeated sentence-openers like `Additionally,`. @@ -118,6 +138,7 @@ Fix: - Wikipedia: `https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing` - Google style guide: `https://developers.google.com/style/tone`, `https://developers.google.com/style/excessive-claims` +- Google/Microsoft docs style: `https://developers.google.com/style/person`, `https://developers.google.com/style/prescriptive-documentation`, `https://developers.google.com/style/notices`, `https://developers.google.com/style/timeless-documentation`, `https://learn.microsoft.com/en-us/contribute/content/style-quick-start`, `https://learn.microsoft.com/en-us/style-guide/word-choice/use-simple-words-concise-sentences`, `https://learn.microsoft.com/en-us/style-guide/grammar/verbs` - Citation fabrication studies: `https://pmc.ncbi.nlm.nih.gov/articles/PMC10484980/`, `https://pmc.ncbi.nlm.nih.gov/articles/PMC11153973/` - Detector-bias and detector-fragility studies: `https://arxiv.org/abs/2304.02819`, `https://arxiv.org/abs/2303.11156` -- Time-drifting marker vocabulary: `https://arxiv.org/abs/2502.09606` +- Time-drifting marker vocabulary and meaning drift: `https://arxiv.org/abs/2502.09606`, `https://arxiv.org/abs/2603.18161` diff --git a/agents/skills/migrate-to-codex/SKILL.md b/agents/skills/migrate-to-codex/SKILL.md new file mode 100644 index 0000000..6c60637 --- /dev/null +++ b/agents/skills/migrate-to-codex/SKILL.md @@ -0,0 +1,31 @@ +--- +name: migrate_to_codex +description: Migrate supported instruction files, skills, agents, and MCP config into Codex project and global files. +--- + +# Migrate to Codex + +## Autonomy + +Keep going until the selected migration is completely done: run the migrator, inspect the report, fix migrated Codex instructions/skills/agents/MCP config, and re-run checks without stopping to ask for confirmation of the next step. If the user has selected a target, do not ask before creating, editing, replacing, or deleting generated Codex artifacts in that target (`AGENTS.md`, `.codex/`, `.agents/`, or `~/.codex/`). Still ask before changing source provider files (`.claude/`, `~/.claude/`, `.opencode/`, and similar), unrelated project code, secrets, or another repository. + +## Steps + +1. Read `references/differences.md` (and refresh Codex docs if its `Docs last checked` date is old). + +2. Dry-run, then run without `--dry-run`, for global and project. Use `--replace` to drop orphan generated skills or agents. + + ```bash + python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py --source ~/.claude/ --target ~/.codex/ --dry-run + python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py --source ~/.claude/ --target ~/.codex/ + python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py --source ./.claude/ --target ./.codex/ --dry-run + python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py --source ./.claude/ --target ./.codex/ + ``` + +3. Read the terminal output and `.codex/migrate-to-codex-report.txt` (on real runs). Fix every `manual_fix_required` and `skipped` row; start with `## MANUAL MIGRATION REQUIRED` in files. + +4. Review in order: `AGENTS.md`, skills, MCP, subagents. + +5. Re-run checks and `--dry-run` after edits. Ask the user about other repos that still need migration. + +Run `python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py --help` for flags (`--scan-only`, defaults, and so on). Deep tables and more links are in `references/differences.md`. diff --git a/agents/skills/migrate-to-codex/agents/openai.yaml b/agents/skills/migrate-to-codex/agents/openai.yaml new file mode 100644 index 0000000..32110f9 --- /dev/null +++ b/agents/skills/migrate-to-codex/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Migrate to Codex" + short_description: "Migrate supported instruction files, skills, agents, and config into Codex" diff --git a/agents/skills/migrate-to-codex/references/differences.md b/agents/skills/migrate-to-codex/references/differences.md new file mode 100644 index 0000000..3f0321d --- /dev/null +++ b/agents/skills/migrate-to-codex/references/differences.md @@ -0,0 +1,184 @@ +# Migration Differences + +## Summary + +This reference only lists migration differences, partial mappings, and unsupported behavior. Direct 1:1 mappings are intentionally omitted. When the converter preserves source-only semantics as prompt guidance, it also emits a `manual_fix_required` report row and writes a `## MANUAL MIGRATION REQUIRED` block into the generated file. + +Docs last checked: 2026-04-06. If today's date is later, re-open the official Codex docs below and the source docs map before trusting these mappings. + +## Instructions + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| Neutral instruction files: `.claude/CLAUDE.md`, `CLAUDE.md`, `claude.md`, `agents.md`, `AGENT.md`, `.agents/AGENTS.md`, `GEMINI.md`, `.config/opencode/AGENTS.md`, `.pi/agent/AGENTS.md`, `CURSOR.md`, `.cursorrules`, `AIDER.md` | `AGENTS.md` symlink | Linked automatically | This keeps one shared instruction body instead of duplicating docs. Provider-specific instructions are already covered through `AGENTS.md` / `CLAUDE.md`. | +| Root `AGENTS.md` | Root `AGENTS.md` | Reported as active | The converter does not overwrite or symlink the target file to itself. | +| Instruction content with `/hooks`, provider-specific subagent routing, or permission-mode assumptions | Generated `AGENTS.md` copy | Manual rewrite pass | The converter intentionally breaks the symlink when obvious source-only semantics need a Codex-specific edit. | + +## OpenCode and PI-CODE source checks + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| OpenCode `AGENTS.md` / `CLAUDE.md` | `AGENTS.md` | Handled by the instruction pass | Root `AGENTS.md` remains the preferred shared instruction target; compatibility fallback files may also be reused. | +| OpenCode `opencode.json` / `opencode.jsonc` / `~/.config/opencode/opencode.json` fields such as `instructions`, `mcp`, `agent`, `plugin`, and `permission` | Mixed Codex config files | Reported as `manual_fix_required` | The schemas are not equivalent; translate instructions, MCP, agents, plugins, and permissions manually. | +| OpenCode `.opencode/agents` / `~/.config/opencode/agents` | `.codex/agents/*.toml` | Reported as `manual_fix_required` | OpenCode markdown agents have different frontmatter and permission semantics. | +| OpenCode `.opencode/commands` / `~/.config/opencode/commands` / `command` config entries with string `template` | `.agents/skills//SKILL.md` | Converted to one-file Codex skills | Slash-command invocation, `$ARGUMENTS`, `$1`, shell-output interpolation, file-reference expansion, `agent`, `subtask`, and `model` metadata still need manual review. | +| OpenCode `.opencode/plugins` / `.opencode/tools` / matching global resource dirs | Codex plugin, MCP, hook, or prompt guidance | Reported as `manual_fix_required` | Plugins can contain event hooks and custom tools; do not import them as legacy plugin marketplaces. | +| OpenCode `.opencode/skills` / `~/.config/opencode/skills` | `.agents/skills` | Reported as `manual_fix_required` | Verify skill structure before copying; compatibility skill formats may coexist. | +| PI-CODE `AGENTS.md` / `CLAUDE.md` | `AGENTS.md` | Handled by the instruction pass | PI-CODE concatenates context files from global and project locations, so check whether multiple files need to be merged. | +| PI-CODE `.pi/settings.json` / `~/.pi/agent/settings.json` | Codex config files | Reported as `manual_fix_required` | PI-CODE settings can include packages and runtime preferences that are not Codex TOML. | +| PI-CODE `.pi/SYSTEM.md` / `.pi/APPEND_SYSTEM.md` / matching global files | `AGENTS.md` or subagent instructions | Reported as `manual_fix_required` | These replace or append to PI-CODE's system prompt and need a manual rewrite. | +| PI-CODE `.pi/extensions` / `~/.pi/agent/extensions` | Codex plugin, MCP, hook, or manual workflow | Reported as `manual_fix_required` | PI-CODE extensions can add tools, commands, UI, hooks, MCP-like behavior, and subagent-like behavior. | +| PI-CODE `.pi/skills` / `~/.pi/agent/skills` | `.agents/skills` | Reported as `manual_fix_required` | PI-CODE follows the Agent Skills standard but can also load package-filtered skills; verify structure before copying. | +| PI-CODE `.pi/prompts` / `~/.pi/agent/prompts` | `.agents/skills//SKILL.md` | Converted to one-file Codex skills | Slash-template invocation and `{{variable}}` placeholders still need manual review. | +| PI-CODE `.pi/git` / `.pi/npm` / matching global package installs | Codex plugins or manual install steps | Reported as `manual_fix_required` | Installed packages can contain extensions, skills, prompts, and themes. Review source before migration. | + +## Commands + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| `.claude/commands/*.md` | `.agents/skills/source-command-/SKILL.md` | Converted to one-file Codex skills | Slash-command invocation, `argument-hint`, `allowed-tools`, `$ARGUMENTS`, shell-output interpolation, and file-reference expansion are preserved as manual-review text. The manual review should focus on runtime behavior, not the source provider name. | +| OpenCode command markdown/config | `.agents/skills/opencode-command-/SKILL.md` | Converted to one-file Codex skills | `agent`, `subtask`, `model`, arguments, shell interpolation, and automatic file expansion are not Codex skill semantics. | +| PI-CODE prompt templates | `.agents/skills/pi-prompt-/SKILL.md` | Converted to one-file Codex skills | Template variables such as `{{variable}}` are preserved as text and need a manual rewrite. | +| Extension-registered commands | No direct equivalent | Reported through extension/package paths | Extensions are executable code, so this converter does not inspect or rewrite registered commands automatically. | +| Command/prompt scripts with runtime expansion | One-file Codex skills plus `manual_fix_required` rows | Preserved as prompt text | Argument placeholders, shell-output interpolation, automatic file expansion, model/agent routing, and executable extension hooks all have different runtime behavior and must be checked manually. | + +## Skills + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| `allowed-tools` | No strict skill allowlist | Preserved as prompt guidance in `SKILL.md` | `agents/openai.yaml` can declare tool dependencies, but that is not a permission boundary. | +| `user-invocable` | `policy.allow_implicit_invocation` | Manual review only | Similar intent, not equivalent semantics. | +| `model` / `effort` | No skill-level model pin | Unsupported | Codex model selection is session/agent scoped in this converter. | +| `disable-model-invocation` | No direct equivalent | Unsupported | Requires a manual rewrite if the source skill depends on this behavior. | +| `argument-hint` / `context` / `agent` / `hooks` / `paths` / `shell` | No direct equivalent | Unsupported | Keep only if the behavior can be rewritten into prompt guidance or config. | + +## MCP and config + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| `type: sse` | No SSE support | Unsupported | Codex supports stdio and streamable HTTP in current docs. | +| `headers.Authorization: Bearer ${VAR}` | `bearer_token_env_var` | Direct auth rewrite | Only the bearer-token shape is rewritten this way; `${VAR:-default}` fallbacks are not preserved. | +| `headers` with `${VAR}` | `env_http_headers` | Partial mapping | Static headers map to `http_headers`; `${VAR:-default}` fallbacks are not preserved. | +| `env` with `${VAR}` | `env_vars` | Partial mapping | Literal values stay in `env`; self-references become `env_vars`, and `${VAR:-default}` fallbacks are not preserved. | +| `oauth.callbackPort` | `mcp_oauth_callback_port` | Partial mapping | `oauth.clientId`, `oauth.authServerMetadataUrl`, and `headersHelper` are unsupported. | +| `enabledMcpjsonServers` / `disabledMcpjsonServers` | Per-server `enabled` | Partial mapping | `enableAllProjectMcpServers` has no direct equivalent in this converter. | +| `allowedMcpServers` / `deniedMcpServers` | `requirements.toml` | Manual policy mapping | Not written by this converter. | +| `.claude/settings.local.json` | No local-only Codex equivalent | Unsupported | Codex project config is tied to trusted project behavior. | + +## Subagents + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| `tools` / `disallowedTools` | No source-style fine-grained agent permissions | Preserved as prompt guidance in `developer_instructions` | Use `sandbox_mode`, `[permissions]`, MCP tool filters, or app tool filters manually when intent is clear. | +| `skills` | No spawn-time preload equivalent | Preserved as prompt guidance in `developer_instructions` | `skills.config` is enable/disable config, not preload behavior. | +| `mcpServers` | No inline subagent MCP config | Unsupported | Use shared Codex MCP config plus manual agent hardening instead. | +| `permissionMode` | `sandbox_mode` | Partial mapping | Only `acceptEdits` and `readOnly` are mapped; `default`, `dontAsk`, `bypassPermissions`, and `plan` are preserved as manual-review prompt guidance. | +| `model` + `effort` | `model` + `model_reasoning_effort` | Partial mapping by model family | Sonnet-family effort is biased one tier higher for coding-agent behavior; source `max` maps to Codex `xhigh`. | +| `hooks` / `memory` / `background` / `isolation` / `maxTurns` | No direct equivalent | Unsupported | Foreground/background and resume behavior do not map cleanly to Codex custom-agent files. | +| `initialPrompt` | No direct equivalent | Unsupported | Only applies when the agent runs as the main source session agent. | +| Auto-delegation by `description` | Automatic or explicit Codex sub-agent spawning | Behavior change | Not a 1:1 match; verify generated agent descriptions manually. | +| Independent agent permissions | Parent sandbox inheritance + runtime overrides | Behavior change | Codex custom-agent files set defaults, not hard isolation from the parent turn. | +| Plugin `agents/` | `.codex/agents/*.toml` | Imported as Codex subagents | Keep plugin agents as agents instead of flattening them into skills. | + +## Plugin marketplaces + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| `.claude-plugin/marketplace.json` | Codex plugins / skills / agents | Reported as `manual_fix_required` only | The migrator does not read `source` paths or copy plugin trees; use plugin-creator (or hand migration). | +| `metadata.pluginRoot` | No direct equivalent | Unsupported | Shorthand plugin sources that depend on `metadata.pluginRoot` need manual layout. | +| Marketplace or `plugin.json` custom `skills` / `agents` paths | No direct equivalent | Unsupported | Map plugin folders yourself; no automated scan. | +| Plugin `commands/` | `.agents/skills//SKILL.md` | Manual | Treat like any other command migration if you copy files by hand. | +| `strict`, `hooks`, `mcpServers`, `lspServers`, `outputStyles` | No direct equivalent | Unsupported | No automatic plugin config import. | +| External `github` / `url` / `git-subdir` / `npm` sources | No package fetch in this converter | Skipped with summary counts | Offer manual install help instead of silently dropping them. | + +## Hooks + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| `hooks` in `~/.claude/settings.json`, `.claude/settings.json`, or `.claude/settings.local.json` | `.codex/hooks.json` + `[features].codex_hooks = true` | Reported as `manual_fix_required` only | The migrator does not emit `hooks.json` or toggle `codex_hooks`; rewrite hooks using current Codex docs. | +| `Notification` | `notify` | Manual rewrite only | `notify` is a turn-complete notification command, not a general lifecycle hook or approval-prompt hook. | +| `PreToolUse` | `PreToolUse` in `.codex/hooks.json` | Manual | Codex currently runs PreToolUse for shell commands only and blocks only `permissionDecision: "deny"`, legacy `decision: "block"`, or exit code `2`. | +| `PostToolUse` | `PostToolUse` in `.codex/hooks.json` | Manual | Codex currently runs PostToolUse for shell commands only; `decision: "block"` becomes model feedback, and `continue: false` stops execution. Formatting or fixups that Claude tied to `Edit`/`Write` should move to a **`Stop`** hook, because only Bash is matched for `PostToolUse`. | +| `UserPromptSubmit` | `UserPromptSubmit` in `.codex/hooks.json` | Manual | Codex can inject context or block a prompt, but it ignores `matcher` for this event and does not support source `if` filters. | +| `SessionStart` | `SessionStart` in `.codex/hooks.json` | Manual | Codex matches `startup` and `resume`; source runtimes may also expose `clear`, `compact`, and environment-file flows. | +| `Stop` | `Stop` in `.codex/hooks.json` | Manual | Codex ignores `matcher` for Stop, can request a continuation prompt, and does not expose every source subagent/teammate stop lifecycle. | +| `PermissionRequest` / `SubagentStart` / `SubagentStop` / `TaskCreated` / `TaskCompleted` / `StopFailure` / `TeammateIdle` / `ConfigChange` / `CwdChanged` / `FileChanged` / `WorktreeCreate` / `WorktreeRemove` / `PreCompact` / `PostCompact` / `SessionEnd` / `Elicitation` / `ElicitationResult` / `InstructionsLoaded` | No direct equivalent | Unsupported | Keep as manual follow-up items; Codex does not expose matching lifecycle coverage today. | +| `type: "command"` | `type: "command"` | Manual | `command`, `timeout` / `timeoutSec`, and `statusMessage` map when you rewrite. Empty commands are skipped by Codex. | +| `type: "prompt"` / `type: "agent"` / `type: "http"` / `async: true` | No direct equivalent | Unsupported | Codex parses `prompt` / `agent` but skips them, and async hooks are skipped. HTTP hooks need a wrapper command. | +| Hook `matcher` + `if` filters | Regex `matcher` only | Manual | As of 2026-04-06, Codex keeps regex `matcher` for `PreToolUse`, `PostToolUse`, and `SessionStart` only. Current Codex runtime only emits `Bash` for `PreToolUse` and `PostToolUse`, so non-`Bash` matchers do not fire. `UserPromptSubmit` and `Stop` matchers are ignored, and source `if` filters do not map. | +| Hooks in skills, agents, and plugins | No direct equivalent | Unsupported | Codex discovers hooks from config layers, not from skill or subagent manifests. | + +## Minimal examples + +Source skill metadata becomes prompt guidance: + +```md +allowed-tools: + - Read + - Bash +``` + +```md +## MANUAL MIGRATION REQUIRED + +Source `allowed-tools` was preserved as prompt guidance, not a Codex permission boundary. + +You're allowed to use these tools: + +- Read +- Bash +``` + +Source subagent metadata becomes TOML plus prompt guidance: + +```md +skills: + - release-notes +tools: + - Read +disallowedTools: + - Bash +``` + +```toml +sandbox_mode = "workspace-write" +developer_instructions = """ +## Skills +- $release-notes + +## Tools +You're allowed to use these tools: +- Read + +Don't use these tools: +- Bash +""" +``` + +## Sources + +- https://docs.claude.com/en/docs/claude-code/claude_code_docs_map +- https://developers.openai.com/codex/config-reference +- https://developers.openai.com/codex/mcp +- https://developers.openai.com/codex/plugins/ +- https://developers.openai.com/codex/plugins/build/ +- https://developers.openai.com/codex/skills +- https://developers.openai.com/codex/subagents +- https://developers.openai.com/codex/hooks +- https://code.claude.com/docs/en/skills +- https://code.claude.com/docs/en/sub-agents +- https://code.claude.com/docs/en/hooks +- https://code.claude.com/docs/en/hooks-guide +- https://code.claude.com/docs/en/mcp +- https://code.claude.com/docs/en/settings +- https://code.claude.com/docs/en/plugins +- https://code.claude.com/docs/en/plugin-marketplaces +- https://opencode.ai/docs/config/ +- https://opencode.ai/docs/rules +- https://opencode.ai/docs/agents/ +- https://opencode.ai/docs/commands/ +- https://opencode.ai/docs/plugins/ +- https://opencode.ai/docs/skills/ +- https://opencode.ai/docs/custom-tools/ +- https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/README.md +- https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/docs/packages.md diff --git a/agents/skills/migrate-to-codex/scripts/cli.py b/agents/skills/migrate-to-codex/scripts/cli.py new file mode 100644 index 0000000..99fb371 --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/cli.py @@ -0,0 +1,2056 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import sys +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, fields as dataclass_fields +from enum import Enum +from pathlib import Path +from typing import TypeAlias + +from migrate.agents import ( + AGENT_SOURCE_ROOTS, + iter_agent_files, +) +from migrate.instructions import ( + INSTRUCTION_SOURCE_CANDIDATES, + instruction_source_file, + should_symlink_instructions, +) +from migrate.settings import ( + CLAUDE_SETTINGS_JSON_RELATIVE, + OPENCODE_CONFIG_FILES, + OPENCODE_CONFIG_KEYS, + OPENCODE_MANUAL_PATHS, + PI_CODE_MANUAL_PATHS, + SOURCE_SCAN_ROOTS, + SOURCE_SCOPE_MARKERS, +) +from migrate.skills import ( + COMMAND_FILE_SOURCES, + SKILL_SOURCE_ROOTS, + command_caveats, + iter_skill_files, +) +from utils.scan import ( + render_scope_inventory, + render_source_inventory, +) +from utils.util import ( + detected_json_keys, + first_markdown_heading, + format_backtick_list, + load_jsonc_object, + normalize_source_scope_root, + read_json_mapping_file, + resolve_source_root, + slugify_name, +) + + +# Constants + +FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.S) +ENV_VAR_RE = re.compile(r"\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}\Z") +BEARER_ENV_VAR_RE = re.compile(r"\ABearer\s+\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}\Z") +DEFAULT_COMPONENTS = frozenset(("mcp", "skills", "subagents")) +MIGRATION_REPORT_PATH = Path(".codex") / "migrate-to-codex-report.txt" +CODEX_CONFIG_PATH = Path(".codex") / "config.toml" +CODEX_AGENTS_ROOT = Path(".codex") / "agents" +CODEX_SKILLS_ROOT = Path(".agents") / "skills" +SUMMARY_LABELS = { + "mcp_servers": "mcp servers", +} +SCOPE_NAMES = ("global", "project") +SKILL_ROOT = Path(__file__).resolve().parents[1] +SKILL_SUPPORT_DIRS = ("scripts", "references", "assets") +SUMMARY_OMIT_WHEN_ZERO = frozenset() +PERMISSION_MODE_MAPPINGS = { + "acceptEdits": "workspace-write", + "readOnly": "read-only", +} +YamlScalar: TypeAlias = str | bool +YamlValue: TypeAlias = YamlScalar | tuple[YamlScalar, ...] + + +# Core models + +@dataclass(frozen=True) +class ScopePaths: + source: Path + is_global: bool + + +@dataclass(frozen=True) +class ModelMapping: + source_prefix: str + target_model: str + effort_mapping: tuple[tuple[str, str], ...] + + def map_effort(self, effort: str) -> str: + for source_effort, target_effort in self.effort_mapping: + if effort == source_effort: + return target_effort + return effort + + +MODEL_PREFIX_MAPPINGS = ( + ModelMapping( + "claude-opus", + "gpt-5.4", + (("low", "low"), ("medium", "medium"), ("high", "high"), ("max", "xhigh")), + ), + ModelMapping( + "claude-sonnet", + "gpt-5.4-mini", + (("low", "medium"), ("medium", "high"), ("high", "xhigh"), ("max", "xhigh")), + ), + ModelMapping( + "claude-haiku", + "gpt-5.4-mini", + (("low", "low"), ("medium", "medium"), ("high", "high"), ("max", "xhigh")), + ), +) + + +class ArtifactKind(Enum): + FILE = "file" + SKILL = "skill" + AGENT = "agent" + + +@dataclass(frozen=True) +class GeneratedText: + content: str + + +@dataclass(frozen=True) +class SourceCopy: + source_path: Path + + +@dataclass(frozen=True) +class SourceSymlink: + source_path: Path + + +ArtifactPayload: TypeAlias = GeneratedText | SourceCopy | SourceSymlink + + +@dataclass(frozen=True) +class WriteTextAction: + target_path: Path + content: str + + +@dataclass(frozen=True) +class CopyFileAction: + source_path: Path + target_path: Path + + +@dataclass(frozen=True) +class CreateSymlinkAction: + source_path: Path + target_path: Path + + +@dataclass(frozen=True) +class DeletePathAction: + path: Path + recursive: bool = False + + +@dataclass(frozen=True) +class WarningAction: + message: str + + +@dataclass(frozen=True) +class MigrationReportItem: + status: str + path: Path + detail: str + + +@dataclass(frozen=True) +class SimpleYamlFrontmatter: + values: dict[str, YamlValue] + + def required_string(self, key: str) -> str: + return str(self.values[key]) + + def optional_string(self, key: str) -> str | None: + value = self.values.get(key) + if value is None: + return None + return str(value) + + def string_tuple(self, key: str) -> tuple[str, ...]: + value = self.values.get(key, ()) + if isinstance(value, tuple): + items = value + else: + items = (value,) + + result: list[str] = [] + for item in items: + result.extend( + split_item + for split_item in (part.strip() for part in str(item).split(",")) + if split_item + ) + return tuple(result) + + def to_dict(self) -> dict[str, YamlValue]: + return self.values + + +@dataclass(frozen=True) +class ParsedDocument: + frontmatter: SimpleYamlFrontmatter + body: str + path: Path | None = None + + @classmethod + def from_file(cls, source_file: Path) -> ParsedDocument: + return parse_frontmatter(source_file.read_text(), source_file) + + +# JSON parsing + +def json_object(value: object) -> Mapping[str, object]: + if isinstance(value, Mapping): + return value + return {} + + +def json_string(value: object) -> str | None: + if value is None: + return None + return str(value) + + +def json_string_tuple(value: object) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return tuple(str(item) for item in value) + return (str(value),) + + +def load_scope_settings(scope_root: Path) -> Mapping[str, object]: + settings: dict[str, object] = {} + for rel in CLAUDE_SETTINGS_JSON_RELATIVE: + outcome = read_json_mapping_file(scope_root / rel) + if outcome.exists and outcome.ok: + settings.update(json_object(outcome.data)) + return settings + + +def format_toml_array(values: Sequence[str]) -> str: + return ", ".join(f'"{value}"' for value in values) + + +def append_toml_entries( + lines: list[str], + table_name: str, + entries: tuple[tuple[str, str], ...], +) -> None: + if not entries: + return + + lines.append("") + lines.append(table_name) + for key, value in entries: + lines.append(f'{key} = "{value}"') + + +def format_bullets(values: Sequence[str], prefix: str = "") -> str: + return "\n".join(f"- {prefix}{value}" for value in values) + + +def format_manual_migration_block(notes: Sequence[str]) -> str: + return ( + "## MANUAL MIGRATION REQUIRED\n\n" + + "\n\n".join(note.rstrip() for note in notes if note.strip()) + ) + + +def unsupported_frontmatter_fields( + frontmatter_values: Mapping[str, YamlValue], + supported_fields: Sequence[str], +) -> tuple[str, ...]: + supported = frozenset(supported_fields) + return tuple( + sorted( + field_name + for field_name in frontmatter_values + if field_name not in supported + ) + ) + + +def append_report_item( + report_items: list[MigrationReportItem], + requires_manual_fix: object, + path: Path, + manual_detail: str, + rewritten_detail: str, +) -> None: + if requires_manual_fix: + report_items.append(manual_report_item(path, manual_detail)) + return + report_items.append(MigrationReportItem("rewritten", path, rewritten_detail)) + + +def manual_report_item(path: Path, detail: str) -> MigrationReportItem: + return MigrationReportItem("manual_fix_required", path, detail) + + +@dataclass(frozen=True) +class McpHeaders: + bearer_token_env_var: str | None = None + static_headers: tuple[tuple[str, str], ...] = () + env_headers: tuple[tuple[str, str], ...] = () + + @classmethod + def from_mapping(cls, headers: Mapping[str, object]) -> McpHeaders: + bearer_token_env_var: str | None = None + static_headers: list[tuple[str, str]] = [] + env_headers: list[tuple[str, str]] = [] + + for key, value in headers.items(): + header_value = str(value) + bearer_match = BEARER_ENV_VAR_RE.match(header_value) + if key.lower() == "authorization" and bearer_match: + bearer_token_env_var = bearer_match.group(1) + continue + + env_match = ENV_VAR_RE.match(header_value) + if env_match: + env_headers.append((key, env_match.group(1))) + continue + + static_headers.append((key, header_value)) + + return cls( + bearer_token_env_var=bearer_token_env_var, + static_headers=tuple(static_headers), + env_headers=tuple(env_headers), + ) + + +@dataclass(frozen=True) +class McpEnv: + static_env: tuple[tuple[str, str], ...] = () + env_vars: tuple[str, ...] = () + + @classmethod + def from_mapping(cls, env: Mapping[str, object]) -> McpEnv: + static_env: list[tuple[str, str]] = [] + env_vars: list[str] = [] + + for key, value in env.items(): + env_value = str(value) + env_match = ENV_VAR_RE.match(env_value) + if env_match and env_match.group(1) == key: + env_vars.append(key) + continue + + static_env.append((key, env_value)) + + return cls( + static_env=tuple(static_env), + env_vars=tuple(env_vars), + ) + + +@dataclass(frozen=True) +class ClaudeMcpServer: + name: str + url: str | None = None + command: str | None = None + args: tuple[str, ...] = () + headers: McpHeaders | None = None + env: McpEnv | None = None + + @classmethod + def from_mapping(cls, name: str, server_config: Mapping[str, object]) -> ClaudeMcpServer: + headers = None + if "headers" in server_config: + headers = McpHeaders.from_mapping(json_object(server_config["headers"])) + + env = None + if "env" in server_config: + env = McpEnv.from_mapping(json_object(server_config["env"])) + + return cls( + name=name, + url=json_string(server_config.get("url")), + command=json_string(server_config.get("command")), + args=json_string_tuple(server_config.get("args")), + headers=headers, + env=env, + ) + + def render_toml_lines( + self, + enabled_servers: tuple[str, ...], + disabled_servers: frozenset[str], + ) -> list[str]: + lines = [f"[mcp_servers.{self.name}]"] + if enabled_servers and self.name not in enabled_servers: + lines.append("enabled = false") + elif self.name in disabled_servers: + lines.append("enabled = false") + if self.url: + lines.append(f'url = "{self.url}"') + if self.command: + lines.append(f'command = "{self.command}"') + if self.args: + lines.append(f"args = [{format_toml_array(self.args)}]") + if self.headers: + if self.headers.bearer_token_env_var: + lines.append( + f'bearer_token_env_var = "{self.headers.bearer_token_env_var}"' + ) + append_toml_entries( + lines, + f"[mcp_servers.{self.name}.http_headers]", + self.headers.static_headers, + ) + append_toml_entries( + lines, + f"[mcp_servers.{self.name}.env_http_headers]", + self.headers.env_headers, + ) + if self.env: + if self.env.env_vars: + lines.append(f"env_vars = [{format_toml_array(self.env.env_vars)}]") + append_toml_entries( + lines, + f"[mcp_servers.{self.name}.env]", + self.env.static_env, + ) + return lines + + +# Claude conversion models + +@dataclass(frozen=True) +class ClaudeSettings: + model: str | None = None + permission_mode: str | None = None + enabled_mcp_servers: tuple[str, ...] = () + disabled_mcp_servers: frozenset[str] = frozenset() + mcp_servers: tuple[ClaudeMcpServer, ...] = () + + @classmethod + def from_scope(cls, scope: ScopePaths) -> ClaudeSettings | None: + claude_mcp = scope.source / ".mcp.json" + claude_global_mcp = scope.source / ".claude.json" + + settings = load_scope_settings(scope.source) + + mcp_config: Mapping[str, object] = {} + if claude_mcp.exists(): + mcp_config = json_object(json.loads(claude_mcp.read_text())) + elif claude_global_mcp.exists(): + mcp_config = json_object(json.loads(claude_global_mcp.read_text())) + + if not settings and not mcp_config: + return None + + mcp_servers = json_object(mcp_config.get("mcpServers")) + return cls( + model=json_string(settings.get("model")), + permission_mode=json_string(settings.get("permissionMode")), + enabled_mcp_servers=json_string_tuple(settings.get("enabledMcpjsonServers")), + disabled_mcp_servers=frozenset( + json_string_tuple(settings.get("disabledMcpjsonServers")) + ), + mcp_servers=tuple( + ClaudeMcpServer.from_mapping(server_name, json_object(server_config)) + for server_name, server_config in mcp_servers.items() + ), + ) + + def render_codex_file(self) -> str: + lines: list[str] = [] + if self.model: + lines.append(f'model = "{map_model_name(self.model)}"') + sandbox_mode = map_permission_mode(self.permission_mode) + if sandbox_mode: + lines.append(f'sandbox_mode = "{sandbox_mode}"') + + for server in self.mcp_servers: + if lines: + lines.append("") + lines.extend( + server.render_toml_lines( + self.enabled_mcp_servers, + self.disabled_mcp_servers, + ) + ) + + return "\n".join(lines) + "\n" + + def to_artifacts(self) -> list[PlannedArtifact]: + return [ + PlannedArtifact( + relative_path=CODEX_CONFIG_PATH, + payload=GeneratedText(self.render_codex_file()), + ) + ] + + +@dataclass(frozen=True) +class ClaudeSkill: + name: str + description: str + body: str = "" + source_path: Path | None = None + allowed_tools: tuple[str, ...] = () + unsupported_fields: tuple[str, ...] = () + + @classmethod + def from_document(cls, document: ParsedDocument) -> ClaudeSkill: + return cls( + name=document.frontmatter.required_string("name"), + description=document.frontmatter.required_string("description"), + body=document.body, + source_path=document.path, + allowed_tools=document.frontmatter.string_tuple("allowed-tools"), + unsupported_fields=unsupported_frontmatter_fields( + document.frontmatter.to_dict(), + ("name", "description", "allowed-tools"), + ), + ) + + def to_frontmatter(self) -> SimpleYamlFrontmatter: + return SimpleYamlFrontmatter( + { + "name": self.name, + "description": self.description, + } + ) + + @classmethod + def from_claude_file(cls, source_file: Path) -> ClaudeSkill: + return cls.from_document(ParsedDocument.from_file(source_file)) + + def to_artifacts(self) -> list[PlannedArtifact]: + if not self.source_path: + raise ValueError("Claude skill is missing source path.") + + artifacts = [ + PlannedArtifact.for_skill(self.source_path, self.render_codex_file()), + ] + artifacts.extend(self.support_artifacts()) + return artifacts + + def support_artifacts(self) -> list[PlannedArtifact]: + if not self.source_path: + raise ValueError("Claude skill is missing source path.") + + artifacts: list[PlannedArtifact] = [] + skill_root = self.source_path.parent + target_root = CODEX_SKILLS_ROOT / skill_root.name + source_files: list[Path] = [] + for dirname in SKILL_SUPPORT_DIRS: + source_dir = skill_root / dirname + if not source_dir.exists(): + continue + source_files.extend( + source_file + for source_file in source_dir.rglob("*") + if source_file.is_file() and is_path_within_root(source_file, skill_root) + ) + for source_file in sorted( + source_files, + key=lambda path: path.relative_to(skill_root).as_posix(), + ): + artifacts.append( + PlannedArtifact.from_source_file( + source_file, + target_root / source_file.relative_to(skill_root), + ) + ) + return artifacts + + def render_codex_file(self) -> str: + return format_frontmatter(self.to_frontmatter(), self.render_codex_body()) + + def render_codex_body(self) -> str: + manual_notes: list[str] = [] + if self.allowed_tools: + manual_notes.append( + "Claude `allowed-tools` was preserved as prompt guidance, not a Codex permission boundary.\n\n" + "You're allowed to use these tools:\n\n" + f"{format_bullets(self.allowed_tools)}" + ) + if self.unsupported_fields: + manual_notes.append( + "Review unsupported Claude skill fields manually: " + f"{', '.join(f'`{field_name}`' for field_name in self.unsupported_fields)}." + ) + + if not manual_notes: + return self.body + + return ( + f"{self.body.rstrip()}\n\n" + f"{format_manual_migration_block(manual_notes)}\n" + ) + + def report_detail(self) -> str: + caveats: list[str] = [] + if self.allowed_tools: + caveats.append("allowed-tools") + caveats.extend(self.unsupported_fields) + if not caveats: + return "Converted Claude skill." + return "Manual review required for Claude skill fields: " + ", ".join( + f"`{field_name}`" for field_name in caveats + ) + "." + + +@dataclass(frozen=True) +class MigratedCommandSkill: + name: str + description: str + body: str + provider: str + source_name: str + caveats: tuple[str, ...] = () + + @classmethod + def from_markdown_file( + cls, + source_root: Path, + source_file: Path, + name_prefix: str, + provider: str, + ) -> MigratedCommandSkill: + document = ParsedDocument.from_file(source_file) + command_name = "-".join(source_file.relative_to(source_root).with_suffix("").parts) + frontmatter = document.frontmatter.to_dict() + description = document.frontmatter.optional_string("description") + if not description: + description = f"Run the migrated {provider} `{command_name}`." + unsupported_fields = unsupported_frontmatter_fields( + frontmatter, + ("description",), + ) + return cls( + name=slugify_name(f"{name_prefix}-{command_name}"), + description=description, + body=document.body, + provider=provider, + source_name=command_name, + caveats=command_caveats(document.body, unsupported_fields), + ) + + @classmethod + def from_opencode_config( + cls, + command_name: str, + command_config: Mapping[str, object], + ) -> MigratedCommandSkill | None: + template = json_string(command_config.get("template")) + if not template: + return None + description = json_string(command_config.get("description")) + if not description: + description = f"Run the migrated OpenCode command `{command_name}`." + unsupported_fields = tuple( + sorted( + field_name + for field_name in command_config + if field_name not in ("template", "description") + ) + ) + return cls( + name=slugify_name(f"opencode-command-{command_name}"), + description=description, + body=template, + provider="OpenCode command", + source_name=command_name, + caveats=command_caveats(template, unsupported_fields), + ) + + def to_artifact(self) -> PlannedArtifact: + return PlannedArtifact( + relative_path=CODEX_SKILLS_ROOT / self.name / "SKILL.md", + payload=GeneratedText(self.render_codex_file()), + kind=ArtifactKind.SKILL, + ) + + def render_codex_file(self) -> str: + frontmatter = SimpleYamlFrontmatter( + { + "name": self.name, + "description": self.description, + } + ) + return format_frontmatter(frontmatter, self.render_codex_body()) + + def render_codex_body(self) -> str: + manual_notes = [ + f"Migrated from {self.provider} `{self.source_name}` into a Codex skill. " + f"Invoke it as `${self.name}` and manually rewrite any slash-command behavior that depended on provider-specific runtime expansion." + ] + manual_notes.extend(self.caveats) + body = self.body.strip() or "No command template body was found." + return ( + f"# {self.name}\n\n" + f"Use this skill when the user asks to run the migrated {self.provider} `{self.source_name}`.\n\n" + "## Command Template\n\n" + f"{body}\n\n" + f"{format_manual_migration_block(manual_notes)}\n" + ) + + def report_detail(self) -> str: + return ( + f"Converted {self.provider} `{self.source_name}` to a single-file Codex skill; " + "review invocation and template placeholder semantics." + ) + + +@dataclass(frozen=True) +class ClaudeAgent: + name: str + description: str + body: str = "" + source_path: Path | None = None + model: str | None = None + permission_mode: str | None = None + skills: tuple[str, ...] = () + tools: tuple[str, ...] = () + disallowed_tools: tuple[str, ...] = () + effort: str | None = None + unsupported_fields: tuple[str, ...] = () + + @classmethod + def from_document(cls, document: ParsedDocument) -> ClaudeAgent: + return cls( + name=document.frontmatter.required_string("name"), + description=document.frontmatter.required_string("description"), + body=document.body, + source_path=document.path, + model=document.frontmatter.optional_string("model"), + permission_mode=document.frontmatter.optional_string("permissionMode"), + skills=document.frontmatter.string_tuple("skills"), + tools=document.frontmatter.string_tuple("tools"), + disallowed_tools=document.frontmatter.string_tuple("disallowedTools"), + effort=document.frontmatter.optional_string("effort"), + unsupported_fields=unsupported_frontmatter_fields( + document.frontmatter.to_dict(), + ( + "name", + "description", + "model", + "permissionMode", + "skills", + "tools", + "disallowedTools", + "effort", + ), + ), + ) + + @classmethod + def from_claude_file(cls, source_file: Path) -> ClaudeAgent: + document = ParsedDocument.from_file(source_file) + inferred_fields: list[str] = [] + name = document.frontmatter.optional_string("name") + if not name: + name = slugify_name(source_file.stem) + inferred_fields.append("name") + + description = document.frontmatter.optional_string("description") + if not description: + heading = first_markdown_heading(document.body) + if heading: + description = ( + f"Migrated Claude subagent inferred from heading `{heading}`." + ) + else: + description = ( + f"Migrated Claude subagent inferred from `{source_file.name}`." + ) + inferred_fields.append("description") + + return cls( + name=name, + description=description, + body=document.body, + source_path=document.path, + model=document.frontmatter.optional_string("model"), + permission_mode=document.frontmatter.optional_string("permissionMode"), + skills=document.frontmatter.string_tuple("skills"), + tools=document.frontmatter.string_tuple("tools"), + disallowed_tools=document.frontmatter.string_tuple("disallowedTools"), + effort=document.frontmatter.optional_string("effort"), + unsupported_fields=unsupported_frontmatter_fields( + document.frontmatter.to_dict(), + ( + "name", + "description", + "model", + "permissionMode", + "skills", + "tools", + "disallowedTools", + "effort", + ), + ) + + tuple(inferred_fields), + ) + + def to_artifacts(self) -> list[PlannedArtifact]: + if not self.source_path: + raise ValueError("Claude agent is missing source path.") + + return [ + PlannedArtifact.for_agent(self.source_path, self.render_codex_file()), + ] + + def render_codex_file(self) -> str: + lines = [ + f'name = "{self.name}"', + f'description = "{self.description}"', + ] + + if self.model: + lines.append(f'model = "{map_model_name(self.model)}"') + if self.effort: + lines.append( + f'model_reasoning_effort = "{map_model_effort(self.model, self.effort)}"' + ) + sandbox_mode = map_permission_mode(self.permission_mode) + if sandbox_mode: + lines.append(f'sandbox_mode = "{sandbox_mode}"') + + lines.extend( + [ + 'developer_instructions = """', + self.render_codex_body().strip(), + '"""', + ] + ) + + return "\n".join(lines) + "\n" + + def render_codex_body(self) -> str: + sections = [] + manual_notes: list[str] = [] + + sandbox_mode = map_permission_mode(self.permission_mode) + if self.permission_mode and not sandbox_mode: + manual_notes.append( + f"Claude `permissionMode: {self.permission_mode}` has no direct Codex mapping. " + "Manually choose `sandbox_mode`, `[permissions]`, MCP tool filters, or app tool filters before relying on this agent." + ) + + if self.skills: + sections.append( + "## Skills\n\n" + "You're allowed to use these skills when working on this task:\n\n" + f"{format_bullets(self.skills, '$')}" + ) + manual_notes.append( + "Claude `skills` preload semantics were preserved as prompt guidance. Verify this agent still discovers the intended skills at runtime." + ) + + if self.tools or self.disallowed_tools: + tool_section_lines = [ + "## Tools", + "", + "Claude tool allow/deny lists were preserved as prompt guidance, not Codex permissions.", + ] + if self.tools: + tool_section_lines.extend( + [ + "", + "You're allowed to use these tools:", + "", + format_bullets(self.tools), + ] + ) + if self.disallowed_tools: + tool_section_lines.extend( + [ + "", + "Don't use these tools:", + "", + format_bullets(self.disallowed_tools), + ] + ) + sections.append("\n".join(tool_section_lines)) + manual_notes.append( + "Rebuild Claude `tools` / `disallowedTools` intent with Codex sandbox, MCP tool filters, or app tool filters if you need hard enforcement." + ) + + if self.unsupported_fields: + manual_notes.append( + "Review unsupported Claude subagent fields manually: " + f"{', '.join(f'`{field_name}`' for field_name in self.unsupported_fields)}." + ) + + if manual_notes: + sections.append(format_manual_migration_block(manual_notes)) + + if not sections: + return self.body + + joined_sections = "\n\n".join(sections) + return f"{self.body.rstrip()}\n\n{joined_sections}\n" + + def report_detail(self) -> str: + caveats: list[str] = [] + if self.skills: + caveats.append("skills") + if self.tools: + caveats.append("tools") + if self.disallowed_tools: + caveats.append("disallowedTools") + if self.permission_mode and not map_permission_mode(self.permission_mode): + caveats.append("permissionMode") + caveats.extend(self.unsupported_fields) + if not caveats: + return "Converted Claude subagent." + return "Manual review required for Claude subagent fields: " + ", ".join( + f"`{field_name}`" for field_name in caveats + ) + "." + + +# Artifact and deployment planning + +@dataclass(frozen=True) +class PlannedArtifact: + relative_path: Path + payload: ArtifactPayload + kind: ArtifactKind = ArtifactKind.FILE + + @classmethod + def for_skill(cls, source_file: Path, content: str) -> PlannedArtifact: + return cls( + relative_path=CODEX_SKILLS_ROOT / source_file.parent.name / "SKILL.md", + payload=GeneratedText(content), + kind=ArtifactKind.SKILL, + ) + + @classmethod + def for_agent(cls, source_file: Path, content: str) -> PlannedArtifact: + return cls( + relative_path=CODEX_AGENTS_ROOT / f"{source_file.stem}.toml", + payload=GeneratedText(content), + kind=ArtifactKind.AGENT, + ) + + @classmethod + def from_source_file(cls, source_file: Path, relative_path: Path) -> PlannedArtifact: + return cls( + relative_path=relative_path, + payload=SourceCopy(source_file), + ) + + def prefixed(self, prefix: Path) -> PlannedArtifact: + return PlannedArtifact( + relative_path=prefix / self.relative_path, + payload=self.payload, + kind=self.kind, + ) + + def without_prefix(self) -> PlannedArtifact: + return PlannedArtifact( + relative_path=Path(*self.relative_path.parts[1:]), + payload=self.payload, + kind=self.kind, + ) + + def target_path(self, target_root: Path) -> Path: + return target_root / self.relative_path + + def write_action( + self, + target_root: Path, + ) -> WriteTextAction | CopyFileAction | CreateSymlinkAction: + target_path = self.target_path(target_root) + if isinstance(self.payload, GeneratedText): + return WriteTextAction(target_path, self.payload.content) + if isinstance(self.payload, SourceSymlink): + return CreateSymlinkAction(self.payload.source_path, target_path) + return CopyFileAction(self.payload.source_path, target_path) + + +@dataclass +class MigrationSummary: + instructions: int = 0 + skills: int = 0 + subagents: int = 0 + mcp_servers: int = 0 + orphaned_skills: int = 0 + orphaned_subagents: int = 0 + + def add(self, other: MigrationSummary) -> None: + for summary_field in dataclass_fields(self): + field_name = summary_field.name + setattr( + self, + field_name, + getattr(self, field_name) + getattr(other, field_name), + ) + + def render(self, deploy_mode: DeployMode, dry_run: bool) -> str: + suffix = " (dry-run)" if dry_run else "" + lines = [ + f"Migration summary{suffix}:", + f" deploy mode: {deploy_mode.value}", + ] + for summary_field in dataclass_fields(self): + field_name = summary_field.name + value = getattr(self, field_name) + if field_name in SUMMARY_OMIT_WHEN_ZERO and value == 0: + continue + label = SUMMARY_LABELS.get(field_name, field_name.replace("_", " ")) + lines.append(f" {label}: {value}") + return "\n".join(lines) + + +@dataclass +class ConversionResult: + summary: MigrationSummary = field(default_factory=MigrationSummary) + artifacts: list[PlannedArtifact] = field(default_factory=list) + report_items: list[MigrationReportItem] = field(default_factory=list) + + def add(self, other: ConversionResult) -> None: + self.summary.add(other.summary) + self.artifacts.extend(other.artifacts) + self.report_items.extend(other.report_items) + + def prefixed(self, prefix: Path) -> ConversionResult: + return ConversionResult( + summary=self.summary, + artifacts=[artifact.prefixed(prefix) for artifact in self.artifacts], + report_items=[ + MigrationReportItem( + item.status, + prefix / item.path, + item.detail, + ) + for item in self.report_items + ], + ) + + +class DeployMode(Enum): + MERGE = "merge" + REPLACE = "replace" + + +@dataclass(frozen=True) +class DeploymentPlan: + artifacts: tuple[PlannedArtifact, ...] + orphaned_skill_dirs: tuple[Path, ...] + orphaned_agent_files: tuple[Path, ...] + colliding_skill_dirs: tuple[Path, ...] + colliding_agent_files: tuple[Path, ...] + summary: MigrationSummary + + def warning_actions(self) -> tuple[WarningAction, ...]: + return tuple( + [ + *( + WarningAction( + f"warning: overwriting existing Codex skill at {collision}" + ) + for collision in self.colliding_skill_dirs + ), + *( + WarningAction( + f"warning: overwriting existing Codex subagent at {collision}" + ) + for collision in self.colliding_agent_files + ), + ] + ) + + def write_actions( + self, + target_root: Path, + ) -> tuple[WriteTextAction | CopyFileAction | CreateSymlinkAction, ...]: + return tuple(artifact.write_action(target_root) for artifact in self.artifacts) + + def delete_actions(self) -> tuple[DeletePathAction, ...]: + return tuple( + [ + *(DeletePathAction(orphan, recursive=True) for orphan in self.orphaned_skill_dirs), + *(DeletePathAction(orphan) for orphan in self.orphaned_agent_files), + ] + ) + + +@dataclass(frozen=True) +class ScopeDeployment: + artifacts: tuple[PlannedArtifact, ...] + target_root: Path + components: frozenset[str] = DEFAULT_COMPONENTS + + def planned_skill_dirs(self) -> frozenset[Path]: + return frozenset( + artifact.relative_path.parent + for artifact in self.artifacts + if artifact.kind == ArtifactKind.SKILL + ) + + def planned_agent_files(self) -> frozenset[Path]: + return frozenset( + artifact.relative_path + for artifact in self.artifacts + if artifact.kind == ArtifactKind.AGENT + ) + + def orphaned_skill_dirs(self) -> list[Path]: + if "skills" not in self.components: + return [] + + target_skills_root = self.target_root / CODEX_SKILLS_ROOT + if not target_skills_root.exists(): + return [] + + planned_skill_dirs = self.planned_skill_dirs() + orphans: list[Path] = [] + for target_skill_dir in target_skills_root.iterdir(): + if not target_skill_dir.is_dir(): + continue + relative_path = CODEX_SKILLS_ROOT / target_skill_dir.name + if relative_path not in planned_skill_dirs: + orphans.append(target_skill_dir) + return orphans + + def orphaned_agent_files(self) -> list[Path]: + if "subagents" not in self.components: + return [] + + target_agents_root = self.target_root / CODEX_AGENTS_ROOT + if not target_agents_root.exists(): + return [] + + planned_agent_files = self.planned_agent_files() + orphans: list[Path] = [] + for target_agent_file in target_agents_root.glob("*.toml"): + relative_path = CODEX_AGENTS_ROOT / target_agent_file.name + if relative_path not in planned_agent_files: + orphans.append(target_agent_file) + return orphans + + def colliding_skill_dirs(self) -> list[Path]: + if "skills" not in self.components: + return [] + + target_skills_root = self.target_root / CODEX_SKILLS_ROOT + if not target_skills_root.exists(): + return [] + + collisions: list[Path] = [] + for relative_path in self.planned_skill_dirs(): + target_skill_dir = self.target_root / relative_path + if target_skill_dir.exists(): + collisions.append(target_skill_dir) + return collisions + + def colliding_agent_files(self) -> list[Path]: + if "subagents" not in self.components: + return [] + + target_agents_root = self.target_root / CODEX_AGENTS_ROOT + if not target_agents_root.exists(): + return [] + + collisions: list[Path] = [] + for relative_path in self.planned_agent_files(): + target_agent_file = self.target_root / relative_path + if target_agent_file.exists(): + collisions.append(target_agent_file) + return collisions + + def plan(self) -> DeploymentPlan: + orphaned_skill_dirs = tuple(self.orphaned_skill_dirs()) + orphaned_agent_files = tuple(self.orphaned_agent_files()) + colliding_skill_dirs = tuple(self.colliding_skill_dirs()) + colliding_agent_files = tuple(self.colliding_agent_files()) + return DeploymentPlan( + artifacts=self.artifacts, + orphaned_skill_dirs=orphaned_skill_dirs, + orphaned_agent_files=orphaned_agent_files, + colliding_skill_dirs=colliding_skill_dirs, + colliding_agent_files=colliding_agent_files, + summary=MigrationSummary( + orphaned_skills=len(orphaned_skill_dirs), + orphaned_subagents=len(orphaned_agent_files), + ), + ) + + +# Conversion orchestration + +def convert_tree( + source_root: Path, + components: frozenset[str] = DEFAULT_COMPONENTS, +) -> ConversionResult: + """Convert a fixture tree containing global/ and project/ Claude scopes.""" + result = ConversionResult() + scopes = [ + ScopePaths(source_root / "global", True), + ScopePaths(source_root / "project", False), + ] + + for scope_name, scope in zip(SCOPE_NAMES, scopes): + if scope.source.exists(): + result.add(convert_scope(scope, components).prefixed(Path(scope_name))) + + if "skills" in components: + result.artifacts.extend(migration_skill_artifacts(source_root)) + return result + + +def convert_scope( + scope: ScopePaths, + components: frozenset[str] = DEFAULT_COMPONENTS, +) -> ConversionResult: + result = ConversionResult() + + result.add(convert_instructions(scope)) + result.add(report_other_agent_sources(scope)) + if "skills" in components: + result.add(convert_skills(scope)) + if "mcp" in components: + result.add(convert_settings(scope)) + if "subagents" in components: + result.add(convert_agents(scope)) + return result + + +def path_exists_with_exact_case(path: Path) -> bool: + if not path.exists(): + return False + try: + return path.name in {child.name for child in path.parent.iterdir()} + except FileNotFoundError: + return False + + +def convert_instructions(scope: ScopePaths) -> ConversionResult: + source_file = instruction_source_file( + scope.source, + scope.is_global, + path_exists_with_exact_case, + ) + if not source_file: + return ConversionResult() + + content = source_file.read_text() + payload: ArtifactPayload + if source_file == scope.source / "AGENTS.md": + report_item = MigrationReportItem( + "rewritten", + Path("AGENTS.md"), + f"Existing Codex instructions already present at {source_file}.", + ) + return ConversionResult( + summary=MigrationSummary(instructions=1), + report_items=[report_item], + ) + if should_symlink_instructions(content): + payload = SourceSymlink(source_file) + report_item = MigrationReportItem( + "symlinked", + Path("AGENTS.md"), + f"Linked to {source_file}.", + ) + else: + manual_block = format_manual_migration_block( + ( + "Claude-only instructions were copied into `AGENTS.md`. Remove Claude hooks, slash commands, and subagent assumptions before relying on this file in Codex.", + ) + ) + payload = GeneratedText( + f"{content.rstrip()}\n\n" + f"{manual_block}\n" + ) + report_item = MigrationReportItem( + "manual_fix_required", + Path("AGENTS.md"), + "Generated copy contains Claude-only instruction semantics.", + ) + return ConversionResult( + summary=MigrationSummary(instructions=1), + artifacts=[ + PlannedArtifact( + relative_path=Path("AGENTS.md"), + payload=payload, + ) + ], + report_items=[report_item], + ) + + +def report_other_agent_sources(scope: ScopePaths) -> ConversionResult: + result = ConversionResult() + result.add(report_opencode_sources(scope)) + result.add(report_pi_code_sources(scope)) + return result + + +def report_opencode_sources(scope: ScopePaths) -> ConversionResult: + result = ConversionResult() + + for config_path in OPENCODE_CONFIG_FILES: + source_file = scope.source / config_path + if not path_exists_with_exact_case(source_file): + continue + detected_keys = detected_json_keys(source_file.read_text(), OPENCODE_CONFIG_KEYS) + if detected_keys: + detail = ( + "Manual review required for OpenCode config fields: " + f"{format_backtick_list(detected_keys)}. " + "This converter does not translate the OpenCode config schema." + ) + else: + detail = ( + "Manual review required for OpenCode config. " + "This converter does not translate the OpenCode config schema." + ) + result.report_items.append(manual_report_item(config_path, detail)) + + result.add(report_manual_paths(scope, OPENCODE_MANUAL_PATHS)) + + return result + + +def report_pi_code_sources(scope: ScopePaths) -> ConversionResult: + return report_manual_paths(scope, PI_CODE_MANUAL_PATHS) + + +def report_manual_paths( + scope: ScopePaths, + path_labels: Sequence[tuple[Path, str]], +) -> ConversionResult: + result = ConversionResult() + + for relative_path, label in path_labels: + if path_exists_with_exact_case(scope.source / relative_path): + result.report_items.append( + manual_report_item( + relative_path, + f"Manual review required for {label}; not converted by this tool.", + ) + ) + + return result + + +def symlink_target(source_path: Path, target_path: Path) -> str: + return os.path.relpath(source_path, target_path.parent) + +def is_path_within_root(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def convert_settings(scope: ScopePaths) -> ConversionResult: + claude_settings = ClaudeSettings.from_scope(scope) + if not claude_settings: + return ConversionResult() + if not claude_settings.render_codex_file().strip(): + return ConversionResult() + return ConversionResult( + summary=MigrationSummary(mcp_servers=len(claude_settings.mcp_servers)), + artifacts=claude_settings.to_artifacts(), + report_items=[ + MigrationReportItem( + "rewritten", + CODEX_CONFIG_PATH, + f"Converted {len(claude_settings.mcp_servers)} MCP server entries.", + ) + ], + ) + + +def convert_skills(scope: ScopePaths) -> ConversionResult: + result = convert_skill_files(scope.source / ".claude" / "skills") + result.add(convert_command_skills(scope)) + return result + + +def convert_agents(scope: ScopePaths) -> ConversionResult: + return convert_agent_files(scope.source / ".claude" / "agents") + + +def convert_skill_files(source_root: Path) -> ConversionResult: + result = ConversionResult() + for source_file in iter_skill_files(source_root): + claude_skill = ClaudeSkill.from_claude_file(source_file) + result.artifacts.extend(claude_skill.to_artifacts()) + result.summary.skills += 1 + skill_path = CODEX_SKILLS_ROOT / source_file.parent.name / "SKILL.md" + append_report_item( + result.report_items, + claude_skill.allowed_tools or claude_skill.unsupported_fields, + skill_path, + claude_skill.report_detail(), + claude_skill.report_detail(), + ) + return result + + +def convert_command_skills(scope: ScopePaths) -> ConversionResult: + result = ConversionResult() + for source_root, name_prefix, provider in COMMAND_FILE_SOURCES: + result.add( + convert_markdown_command_files( + scope.source / source_root, + name_prefix, + provider, + ) + ) + result.add(convert_opencode_config_commands(scope)) + return result + + +def command_skill_path(command_skill: MigratedCommandSkill) -> Path: + return CODEX_SKILLS_ROOT / command_skill.name / "SKILL.md" + + +def append_command_skill( + result: ConversionResult, + command_skill: MigratedCommandSkill, +) -> None: + result.artifacts.append(command_skill.to_artifact()) + result.summary.skills += 1 + result.report_items.append( + manual_report_item( + command_skill_path(command_skill), + command_skill.report_detail(), + ) + ) + + +def convert_markdown_command_files( + source_root: Path, + name_prefix: str, + provider: str, +) -> ConversionResult: + result = ConversionResult() + if not source_root.exists(): + return result + for source_file in sorted(source_root.rglob("*.md")): + append_command_skill( + result, + MigratedCommandSkill.from_markdown_file( + source_root, + source_file, + name_prefix, + provider, + ) + ) + return result + + +def convert_opencode_config_commands(scope: ScopePaths) -> ConversionResult: + result = ConversionResult() + for config_path in OPENCODE_CONFIG_FILES: + config_file = scope.source / config_path + if not path_exists_with_exact_case(config_file): + continue + try: + config = load_jsonc_object(config_file.read_text(), json_object) + except json.JSONDecodeError: + if detected_json_keys(config_file.read_text(), ("command",)): + result.report_items.append( + manual_report_item( + config_path, + "OpenCode `command` config was detected but could not be parsed; commands were not converted.", + ) + ) + continue + command_entries = json_object(config.get("command")) + for command_name, command_config in command_entries.items(): + command_skill = MigratedCommandSkill.from_opencode_config( + command_name, + json_object(command_config), + ) + if not command_skill: + result.report_items.append( + manual_report_item( + config_path, + f"OpenCode command `{command_name}` has no string `template`; it was not converted.", + ) + ) + continue + append_command_skill(result, command_skill) + return result + + +def convert_agent_files(source_root: Path) -> ConversionResult: + result = ConversionResult() + for source_file in iter_agent_files(source_root): + claude_agent = ClaudeAgent.from_claude_file(source_file) + result.artifacts.extend(claude_agent.to_artifacts()) + result.summary.subagents += 1 + agent_path = CODEX_AGENTS_ROOT / f"{source_file.stem}.toml" + append_report_item( + result.report_items, + claude_agent.skills + or claude_agent.tools + or claude_agent.disallowed_tools + or ( + claude_agent.permission_mode + and not map_permission_mode(claude_agent.permission_mode) + ) + or claude_agent.unsupported_fields, + agent_path, + claude_agent.report_detail(), + claude_agent.report_detail(), + ) + return result + + +def has_artifact_path( + conversion_result: ConversionResult, + suffix: str, +) -> bool: + return any( + artifact.relative_path.as_posix().endswith(suffix) + for artifact in conversion_result.artifacts + ) + + +def surface_line(status: str, surface: str, detail: str) -> str: + return f" {status}: {surface} - {detail}" + + +def render_migration_surfaces( + conversion_result: ConversionResult, + components: frozenset[str], +) -> str: + summary = conversion_result.summary + lines = ["", "Migration surfaces:"] + + if summary.instructions: + lines.append( + surface_line( + "active", + "AGENTS.md", + f"{summary.instructions} instruction file(s) found.", + ) + ) + else: + lines.append( + surface_line( + "inactive", + "AGENTS.md", + "No supported instruction file found.", + ) + ) + + if "skills" not in components: + lines.append(surface_line("inactive", "skills", "Not selected by CLI flags.")) + elif summary.skills: + lines.append( + surface_line( + "active", + "skills", + f"{summary.skills} skill(s) converted.", + ) + ) + else: + lines.append(surface_line("inactive", "skills", "No skills found.")) + + if "mcp" not in components: + lines.append(surface_line("inactive", "MCP config", "Not selected by CLI flags.")) + elif has_artifact_path(conversion_result, ".codex/config.toml"): + lines.append( + surface_line( + "active", + "MCP config", + f"{summary.mcp_servers} MCP server(s) converted into config.toml.", + ) + ) + else: + lines.append( + surface_line( + "inactive", + "MCP config", + "No settings or MCP config found.", + ) + ) + + if "subagents" not in components: + lines.append(surface_line("inactive", "subagents", "Not selected by CLI flags.")) + elif summary.subagents: + lines.append( + surface_line( + "active", + "subagents", + f"{summary.subagents} subagent(s) converted.", + ) + ) + else: + lines.append(surface_line("inactive", "subagents", "No subagents found.")) + + return "\n".join(lines) + + +def render_migration_report( + report_items: Sequence[MigrationReportItem], + deployment_plan: DeploymentPlan, + deploy_mode: DeployMode, + dry_run: bool, +) -> str: + lines = ["", "Migration report:"] + for item in report_items: + lines.append(f" {item.status}: {item.path.as_posix()} - {item.detail}") + for collision in deployment_plan.colliding_skill_dirs: + lines.append( + f" overwritten: {collision.as_posix()} - Existing Codex skill will be replaced." + ) + for collision in deployment_plan.colliding_agent_files: + lines.append( + f" overwritten: {collision.as_posix()} - Existing Codex subagent will be replaced." + ) + if deploy_mode == DeployMode.REPLACE: + orphan_status = "would_delete" if dry_run else "deleted" + for orphan in deployment_plan.orphaned_skill_dirs: + lines.append( + f" {orphan_status}: {orphan.as_posix()} - Orphaned generated skill." + ) + for orphan in deployment_plan.orphaned_agent_files: + lines.append( + f" {orphan_status}: {orphan.as_posix()} - Orphaned generated subagent." + ) + return "\n".join(lines) + + +def write_migration_report(target_root: Path, report_text: str) -> None: + report_path = target_root / MIGRATION_REPORT_PATH + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(f"{report_text.lstrip()}\n") + + +# Parsing and rendering helpers + +def parse_frontmatter(content: str, path: Path | None = None) -> ParsedDocument: + match = FRONTMATTER_RE.match(content) + if not match: + return ParsedDocument(SimpleYamlFrontmatter({}), content, path) + + raw_frontmatter, body = match.groups() + return ParsedDocument(parse_simple_yaml(raw_frontmatter), body, path) + + +def parse_simple_yaml(content: str) -> SimpleYamlFrontmatter: + result: dict[str, YamlValue] = {} + current_key: str | None = None + + for line in content.splitlines(): + if not line.strip(): + continue + if line.startswith(" - ") and current_key: + current_value = result.get(current_key, ()) + if not isinstance(current_value, tuple): + current_value = (current_value,) + result[current_key] = (*current_value, parse_scalar(line[4:])) + continue + key, _, value = line.partition(":") + current_key = key.strip() + value = value.strip() + if value: + result[current_key] = parse_scalar(value) + else: + result[current_key] = () + + return SimpleYamlFrontmatter(result) + + +def parse_scalar(value: str) -> YamlScalar: + if value == "true": + return True + if value == "false": + return False + return value.strip('"') + + +def format_frontmatter(frontmatter: SimpleYamlFrontmatter, body: str) -> str: + lines = ["---"] + for key, value in frontmatter.to_dict().items(): + lines.append(f"{key}: {value}") + lines.append("---") + lines.append("") + lines.append(body.lstrip()) + return "\n".join(lines) + + +def map_model_name(model: str) -> str: + for mapping in MODEL_PREFIX_MAPPINGS: + if model.startswith(mapping.source_prefix): + return mapping.target_model + return model + + +def map_model_effort(model: str | None, effort: str) -> str: + if not model: + return effort + for mapping in MODEL_PREFIX_MAPPINGS: + if model.startswith(mapping.source_prefix): + return mapping.map_effort(effort) + return effort + + +def map_permission_mode(permission_mode: str | None) -> str | None: + if not permission_mode: + return None + return PERMISSION_MODE_MAPPINGS.get(permission_mode) + + +# Deployment orchestration + +def deploy_tree( + conversion_result: ConversionResult, + target_root: Path, + components: frozenset[str] = DEFAULT_COMPONENTS, +) -> DeploymentPlan: + summary = MigrationSummary() + artifacts: list[PlannedArtifact] = [] + orphaned_skill_dirs: list[Path] = [] + orphaned_agent_files: list[Path] = [] + colliding_skill_dirs: list[Path] = [] + colliding_agent_files: list[Path] = [] + for scope_name in SCOPE_NAMES: + prefixed_scope_artifacts = tuple( + artifact + for artifact in conversion_result.artifacts + if artifact.relative_path.parts and artifact.relative_path.parts[0] == scope_name + ) + scope_artifacts = tuple( + artifact.without_prefix() + for artifact in prefixed_scope_artifacts + ) + if not scope_artifacts: + continue + scope_plan = ScopeDeployment( + scope_artifacts, + target_root / scope_name, + components, + ).plan() + summary.add(scope_plan.summary) + artifacts.extend(prefixed_scope_artifacts) + orphaned_skill_dirs.extend(scope_plan.orphaned_skill_dirs) + orphaned_agent_files.extend(scope_plan.orphaned_agent_files) + colliding_skill_dirs.extend(scope_plan.colliding_skill_dirs) + colliding_agent_files.extend(scope_plan.colliding_agent_files) + return DeploymentPlan( + artifacts=tuple(artifacts), + orphaned_skill_dirs=tuple(orphaned_skill_dirs), + orphaned_agent_files=tuple(orphaned_agent_files), + colliding_skill_dirs=tuple(colliding_skill_dirs), + colliding_agent_files=tuple(colliding_agent_files), + summary=summary, + ) + + +def migration_skill_artifacts(source_root: Path) -> list[PlannedArtifact]: + artifacts: list[PlannedArtifact] = [] + for scope_name in SCOPE_NAMES: + if not (source_root / scope_name).exists(): + continue + artifacts.append( + PlannedArtifact( + relative_path=Path(scope_name) + / ".agents" + / "skills" + / "migrate-to-codex" + / "SKILL.md", + payload=SourceCopy(SKILL_ROOT / "SKILL.md"), + kind=ArtifactKind.SKILL, + ) + ) + artifacts.append( + PlannedArtifact.from_source_file( + SKILL_ROOT / "references" / "differences.md", + Path(scope_name) + / ".agents" + / "skills" + / "migrate-to-codex" + / "references" + / "differences.md", + ) + ) + return artifacts + + +def normalize_scope_root(path: Path, marker: str) -> Path: + if path.name == marker: + return path.parent + return path + + +def selected_components(args: argparse.Namespace) -> frozenset[str]: + components = { + component + for component in ("mcp", "skills", "subagents") + if getattr(args, component, False) + } + if not components: + return DEFAULT_COMPONENTS + return frozenset(components) + + +# CLI + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Claude-style source tree to Codex (--target). " + "Omit --mcp/--skills/--subagents to run all three. " + "See migrate-to-codex SKILL.md." + ), + ) + parser.add_argument("--source", required=True, help="Source root (optional global/ + project/ subdirs).") + parser.add_argument("--target", help="Codex root (required unless --scan-only).") + parser.add_argument("--mcp", action="store_true", help="Write MCP/settings to config.toml.") + parser.add_argument("--skills", action="store_true", help="Write skills under .agents/skills.") + parser.add_argument("--subagents", action="store_true", help="Write agents under .codex/agents.") + parser.add_argument("--scan-sources", action="store_true", help="Print source inventory before migrate.") + parser.add_argument("--scan-only", action="store_true", help="Inventory only; omit --target.") + deploy_group = parser.add_mutually_exclusive_group() + deploy_group.add_argument( + "--merge", + action="store_true", + help="Keep orphan generated skills/agents (default).", + ) + deploy_group.add_argument( + "--replace", + action="store_true", + help="Remove orphan generated skills/agents for selected surfaces.", + ) + parser.add_argument("--dry-run", action="store_true", help="Print report; do not write files.") + args = parser.parse_args() + + source_root = resolve_source_root(args.source) + if not source_root.exists(): + normalized_candidate = normalize_source_scope_root( + source_root, + SOURCE_SCOPE_MARKERS, + ) + if normalized_candidate.exists(): + source_root = normalized_candidate + else: + raise SystemExit(f"Missing source root: {source_root}") + + if args.scan_only and args.target: + parser.error("--scan-only does not use --target.") + if not args.scan_only and not args.target: + parser.error("--target is required unless --scan-only is set.") + + if args.scan_only: + if (source_root / "global").exists() and (source_root / "project").exists(): + print( + render_source_inventory( + source_root / "global", + SOURCE_SCAN_ROOTS, + path_exists_with_exact_case, + ) + ) + print( + render_scope_inventory( + source_root / "global", + INSTRUCTION_SOURCE_CANDIDATES, + COMMAND_FILE_SOURCES, + SKILL_SOURCE_ROOTS, + AGENT_SOURCE_ROOTS, + iter_skill_files, + iter_agent_files, + path_exists_with_exact_case, + ) + ) + print( + render_source_inventory( + source_root / "project", + SOURCE_SCAN_ROOTS, + path_exists_with_exact_case, + ) + ) + print( + render_scope_inventory( + source_root / "project", + INSTRUCTION_SOURCE_CANDIDATES, + COMMAND_FILE_SOURCES, + SKILL_SOURCE_ROOTS, + AGENT_SOURCE_ROOTS, + iter_skill_files, + iter_agent_files, + path_exists_with_exact_case, + ) + ) + else: + normalized_source_root = normalize_source_scope_root( + source_root, + SOURCE_SCOPE_MARKERS, + ) + print(render_source_inventory(normalized_source_root, SOURCE_SCAN_ROOTS, path_exists_with_exact_case)) + print( + render_scope_inventory( + normalized_source_root, + INSTRUCTION_SOURCE_CANDIDATES, + COMMAND_FILE_SOURCES, + SKILL_SOURCE_ROOTS, + AGENT_SOURCE_ROOTS, + iter_skill_files, + iter_agent_files, + path_exists_with_exact_case, + ) + ) + return + + target_root = Path(args.target) + components = selected_components(args) + deploy_mode = DeployMode.REPLACE if args.replace else DeployMode.MERGE + + if (source_root / "global").exists() and (source_root / "project").exists(): + conversion_result = convert_tree(source_root, components) + deployment_target_root = target_root + deployment_plan = deploy_tree( + conversion_result, + deployment_target_root, + components, + ) + else: + source_scope_root = normalize_scope_root(source_root, ".claude") + deployment_target_root = normalize_scope_root(target_root, ".codex") + scope = ScopePaths( + source_scope_root, + source_scope_root == Path.home(), + ) + conversion_result = convert_scope(scope, components) + deployment_plan = ScopeDeployment( + tuple(conversion_result.artifacts), + deployment_target_root, + components, + ).plan() + + conversion_result.summary.add(deployment_plan.summary) + source_inventory = "" + migration_inventory = "" + if args.scan_sources: + if (source_root / "global").exists() and (source_root / "project").exists(): + source_inventory = ( + render_source_inventory( + source_root / "global", + SOURCE_SCAN_ROOTS, + path_exists_with_exact_case, + ) + + "\n" + + render_source_inventory( + source_root / "project", + SOURCE_SCAN_ROOTS, + path_exists_with_exact_case, + ) + ) + else: + source_inventory = render_source_inventory( + normalize_source_scope_root(source_root, SOURCE_SCOPE_MARKERS), + SOURCE_SCAN_ROOTS, + path_exists_with_exact_case, + ) + if (source_root / "global").exists() and (source_root / "project").exists(): + migration_inventory = ( + render_scope_inventory( + source_root / "global", + INSTRUCTION_SOURCE_CANDIDATES, + COMMAND_FILE_SOURCES, + SKILL_SOURCE_ROOTS, + AGENT_SOURCE_ROOTS, + iter_skill_files, + iter_agent_files, + path_exists_with_exact_case, + ) + + "\n" + + render_scope_inventory( + source_root / "project", + INSTRUCTION_SOURCE_CANDIDATES, + COMMAND_FILE_SOURCES, + SKILL_SOURCE_ROOTS, + AGENT_SOURCE_ROOTS, + iter_skill_files, + iter_agent_files, + path_exists_with_exact_case, + ) + ) + else: + migration_inventory = render_scope_inventory( + normalize_source_scope_root(source_root, SOURCE_SCOPE_MARKERS), + INSTRUCTION_SOURCE_CANDIDATES, + COMMAND_FILE_SOURCES, + SKILL_SOURCE_ROOTS, + AGENT_SOURCE_ROOTS, + iter_skill_files, + iter_agent_files, + path_exists_with_exact_case, + ) + migration_surfaces = render_migration_surfaces(conversion_result, components) + migration_report = render_migration_report( + conversion_result.report_items, + deployment_plan, + deploy_mode, + args.dry_run, + ) + for action in deployment_plan.warning_actions(): + print(action.message, file=sys.stderr) + if not args.dry_run: + for action in deployment_plan.write_actions(deployment_target_root): + action.target_path.parent.mkdir(parents=True, exist_ok=True) + if action.target_path.is_symlink(): + action.target_path.unlink() + if isinstance(action, WriteTextAction): + action.target_path.write_text(action.content) + continue + if isinstance(action, CreateSymlinkAction): + if action.target_path.exists(): + action.target_path.unlink() + action.target_path.symlink_to( + symlink_target(action.source_path, action.target_path) + ) + continue + shutil.copy2(action.source_path, action.target_path) + if deploy_mode == DeployMode.REPLACE: + for action in deployment_plan.delete_actions(): + if action.recursive: + shutil.rmtree(action.path) + continue + action.path.unlink() + write_migration_report( + deployment_target_root, + f"{source_inventory}{migration_inventory}{migration_surfaces}{migration_report}", + ) + print(conversion_result.summary.render(deploy_mode, args.dry_run)) + if source_inventory: + print(source_inventory) + if migration_inventory: + print(migration_inventory) + print(migration_surfaces) + print(migration_report) + + +if __name__ == "__main__": + main() diff --git a/agents/skills/migrate-to-codex/scripts/migrate-to-codex.py b/agents/skills/migrate-to-codex/scripts/migrate-to-codex.py new file mode 100644 index 0000000..d858aa5 --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/migrate-to-codex.py @@ -0,0 +1,5 @@ +from cli import main + + +if __name__ == "__main__": + main() diff --git a/agents/skills/migrate-to-codex/scripts/migrate/__init__.py b/agents/skills/migrate-to-codex/scripts/migrate/__init__.py new file mode 100644 index 0000000..5bbc9e3 --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/migrate/__init__.py @@ -0,0 +1 @@ +# Migration surface modules. diff --git a/agents/skills/migrate-to-codex/scripts/migrate/agents.py b/agents/skills/migrate-to-codex/scripts/migrate/agents.py new file mode 100644 index 0000000..08ebad0 --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/migrate/agents.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + + +AGENT_SOURCE_ROOTS = ( + Path(".claude") / "agents", + Path(".opencode") / "agents", + Path(".config") / "opencode" / "agents", +) + + +def iter_agent_files(source_root: Path) -> tuple[Path, ...]: + if not source_root.exists(): + return () + return tuple( + source_file + for source_file in sorted(source_root.glob("*.md")) + if source_file.stem != "README" + ) diff --git a/agents/skills/migrate-to-codex/scripts/migrate/instructions.py b/agents/skills/migrate-to-codex/scripts/migrate/instructions.py new file mode 100644 index 0000000..e605eed --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/migrate/instructions.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +INSTRUCTION_SOURCE_CANDIDATES = ( + Path(".claude") / "CLAUDE.md", + Path("CLAUDE.md"), + Path("claude.md"), + Path("AGENTS.md"), + Path("agents.md"), + Path("AGENT.md"), + Path("agent.md"), + Path(".agents.md"), + Path(".agents") / "AGENTS.md", + Path(".agents") / "agents.md", + Path("GEMINI.md"), + Path("gemini.md"), + Path(".config") / "opencode" / "AGENTS.md", + Path(".pi") / "agent" / "AGENTS.md", + Path("CURSOR.md"), + Path(".cursorrules"), + Path("AIDER.md"), +) + +CLAUDE_ONLY_INSTRUCTION_MARKERS = ( + "/hooks", + ".claude/agents/", + ".claude/settings", + "Subagent", + "subagent", + "permissionMode", + "ExitPlanMode", +) + + +def instruction_source_file( + source_root: Path, + is_global: bool, + path_exists_with_exact_case: Callable[[Path], bool], +) -> Path | None: + candidates = INSTRUCTION_SOURCE_CANDIDATES + if not is_global: + candidates = tuple( + candidate + for candidate in candidates + if candidate != Path(".claude") / "CLAUDE.md" + ) + + for candidate in candidates: + source_file = source_root / candidate + if path_exists_with_exact_case(source_file): + return source_file + return None + + +def should_symlink_instructions(content: str) -> bool: + return not any(marker in content for marker in CLAUDE_ONLY_INSTRUCTION_MARKERS) diff --git a/agents/skills/migrate-to-codex/scripts/migrate/settings.py b/agents/skills/migrate-to-codex/scripts/migrate/settings.py new file mode 100644 index 0000000..f11f3e4 --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/migrate/settings.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +"""Shared path constants for migration. + +Paths in CLAUDE_* are relative to ScopePaths.source (the directory that contains +``.claude``, ``.mcp.json``, etc.). +""" + +from pathlib import Path + +CLAUDE_SETTINGS_JSON_RELATIVE = ( + Path(".claude") / "settings.json", + Path(".claude") / "settings.local.json", +) + +OPENCODE_CONFIG_FILES = ( + Path("opencode.json"), + Path("opencode.jsonc"), + Path(".config") / "opencode" / "opencode.json", + Path(".config") / "opencode" / "opencode.jsonc", +) + +OPENCODE_CONFIG_KEYS = ( + "instructions", + "mcp", + "agent", + "plugin", + "permission", +) + +_OPENCODE_MANUAL_SEGMENTS: tuple[tuple[str, ...], str] = ( + (("agents",), "markdown agents"), + (("plugins",), "plugins and plugin hooks"), + (("skills",), "skills"), + (("tools",), "custom tools"), +) + + +def _build_opencode_manual_paths() -> tuple[tuple[Path, str], ...]: + rows: list[tuple[Path, str]] = [] + for base, prefix in ( + (Path(".opencode"), "OpenCode "), + (Path(".config") / "opencode", "OpenCode global "), + ): + for segments, tail in _OPENCODE_MANUAL_SEGMENTS: + rows.append((base.joinpath(*segments), f"{prefix}{tail}")) + return tuple(rows) + + +OPENCODE_MANUAL_PATHS = _build_opencode_manual_paths() + +_PI_CODE_MANUAL_SEGMENTS: tuple[tuple[str, ...], str] = ( + (("settings.json",), "settings"), + (("SYSTEM.md",), "replacement system prompt"), + (("APPEND_SYSTEM.md",), "appended system prompt"), + (("extensions",), "extensions, tools, commands, and hooks"), + (("skills",), "skills"), + (("git",), "git package cache"), + (("npm",), "npm package cache"), +) + + +def _build_pi_code_manual_paths() -> tuple[tuple[Path, str], ...]: + rows: list[tuple[Path, str]] = [] + pi = Path(".pi") + agent = pi / "agent" + for segments, tail in _PI_CODE_MANUAL_SEGMENTS: + rows.append((pi.joinpath(*segments), f"PI-CODE {tail}")) + for segments, tail in _PI_CODE_MANUAL_SEGMENTS: + rows.append((agent.joinpath(*segments), f"PI-CODE global {tail}")) + return tuple(rows) + + +PI_CODE_MANUAL_PATHS = _build_pi_code_manual_paths() + +SOURCE_SCAN_ROOTS = ( + (Path(".claude"), "primary source"), + (Path(".opencode"), "OpenCode"), + (Path(".config") / "opencode", "OpenCode global"), + (Path(".pi"), "PI-CODE"), + (Path(".pi") / "agent", "PI-CODE global"), +) + +SOURCE_SCOPE_MARKERS = ( + Path(".claude"), + Path(".opencode"), + Path(".pi"), + Path(".config") / "opencode", +) diff --git a/agents/skills/migrate-to-codex/scripts/migrate/skills.py b/agents/skills/migrate-to-codex/scripts/migrate/skills.py new file mode 100644 index 0000000..91e087a --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/migrate/skills.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import re +from collections.abc import Sequence +from pathlib import Path + + +COMMAND_FILE_SOURCES = ( + (Path(".claude") / "commands", "source-command", "source command"), + (Path(".opencode") / "commands", "opencode-command", "OpenCode command"), + ( + Path(".config") / "opencode" / "commands", + "opencode-command", + "OpenCode global command", + ), + (Path(".pi") / "prompts", "pi-prompt", "PI-CODE prompt template"), + ( + Path(".pi") / "agent" / "prompts", + "pi-prompt", + "PI-CODE global prompt template", + ), +) + +SKILL_SOURCE_ROOTS = ( + Path(".claude") / "skills", + Path(".opencode") / "skills", + Path(".config") / "opencode" / "skills", + Path(".pi") / "skills", + Path(".pi") / "agent" / "skills", +) + + +def iter_skill_files(source_root: Path) -> tuple[Path, ...]: + if not source_root.exists(): + return () + return tuple(sorted(source_root.glob("*/SKILL.md"))) + + +def command_caveats( + template: str, + unsupported_fields: Sequence[str], +) -> tuple[str, ...]: + caveats: list[str] = [] + if re.search(r"\$(ARGUMENTS|\d+)\b", template): + caveats.append( + "Provider argument placeholders like `$ARGUMENTS` or `$1` were preserved as text; rewrite them into natural-language instructions for Codex." + ) + if "{{" in template and "}}" in template: + caveats.append( + "Provider template variables like `{{name}}` were preserved as text; rewrite them into natural-language instructions for Codex." + ) + if re.search(r"!\s*`", template): + caveats.append( + "Provider shell-output interpolation like ``!`command` `` was preserved as text; replace it with explicit Codex instructions to run the command when needed." + ) + if re.search(r"(^|\s)@[\w./~:-]+", template): + caveats.append( + "Provider automatic file-reference expansion was preserved as text; verify Codex should read those files explicitly." + ) + if unsupported_fields: + caveats.append( + "Review unsupported command metadata manually: " + + ", ".join(f"`{field_name}`" for field_name in unsupported_fields) + + "." + ) + return tuple(caveats) diff --git a/agents/skills/migrate-to-codex/scripts/utils/__init__.py b/agents/skills/migrate-to-codex/scripts/utils/__init__.py new file mode 100644 index 0000000..211a97f --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/utils/__init__.py @@ -0,0 +1 @@ +# Migration script helper modules. diff --git a/agents/skills/migrate-to-codex/scripts/utils/scan.py b/agents/skills/migrate-to-codex/scripts/utils/scan.py new file mode 100644 index 0000000..bf116db --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/utils/scan.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from pathlib import Path + + +def should_skip_inventory_child(child: Path) -> bool: + return child.name in {".DS_Store", "__pycache__"} + + +def command_file_inventory( + source_root: Path, + command_file_sources: Sequence[tuple[Path, str, str]], +) -> tuple[tuple[str, tuple[str, ...]], ...]: + inventory: list[tuple[str, tuple[str, ...]]] = [] + for relative_root, _name_prefix, provider in command_file_sources: + absolute_root = source_root / relative_root + if not absolute_root.exists(): + continue + command_names = tuple( + sorted( + source_file.relative_to(absolute_root).with_suffix("").as_posix() + for source_file in absolute_root.rglob("*.md") + ) + ) + if command_names: + inventory.append((provider, command_names)) + return tuple(inventory) + + +def render_named_inventory( + lines: list[str], + label: str, + values: Sequence[str], +) -> None: + if not values: + lines.append(f" inactive: {label} - none found") + return + lines.append(f" active: {label} - {len(values)} found") + for value in values: + lines.append(f" - {value}") + + +def render_scope_inventory( + source_root: Path, + instruction_source_candidates: Sequence[Path], + command_file_sources: Sequence[tuple[Path, str, str]], + skill_source_roots: Sequence[Path], + agent_source_roots: Sequence[Path], + iter_skill_files: Callable[[Path], Sequence[Path]], + iter_agent_files: Callable[[Path], Sequence[Path]], + path_exists_with_exact_case: Callable[[Path], bool], +) -> str: + lines = ["", "Migration inventory:"] + instruction_candidates = tuple( + candidate.as_posix() + for candidate in instruction_source_candidates + if path_exists_with_exact_case(source_root / candidate) + ) + skill_names = tuple( + sorted( + { + source_file.parent.name + for relative_root in skill_source_roots + for source_file in iter_skill_files(source_root / relative_root) + } + ) + ) + agent_names = tuple( + sorted( + { + source_file.stem + for relative_root in agent_source_roots + for source_file in iter_agent_files(source_root / relative_root) + } + ) + ) + + render_named_inventory(lines, "instruction files", instruction_candidates) + render_named_inventory(lines, "skills", skill_names) + + command_inventory = command_file_inventory(source_root, command_file_sources) + if not command_inventory: + lines.append(" inactive: command sources - none found") + else: + total_commands = sum(len(command_names) for _, command_names in command_inventory) + lines.append(f" active: command sources - {total_commands} found") + for provider, command_names in command_inventory: + lines.append(f" provider: {provider} ({len(command_names)})") + for command_name in command_names: + lines.append(f" - {command_name}") + + render_named_inventory(lines, "subagents", agent_names) + return "\n".join(lines) + + +def render_source_inventory( + source_root: Path, + source_scan_roots: Sequence[tuple[Path, str]], + path_exists_with_exact_case: Callable[[Path], bool], +) -> str: + lines = ["", "Source inventory:"] + discovered = False + + for relative_root, label in source_scan_roots: + absolute_root = source_root / relative_root + if not path_exists_with_exact_case(absolute_root): + continue + discovered = True + lines.append(f" detected: {relative_root.as_posix()} - {label}") + try: + children = sorted(absolute_root.iterdir(), key=lambda child: child.name.lower()) + except FileNotFoundError: + continue + for child in children: + if should_skip_inventory_child(child): + continue + child_kind = "dir" if child.is_dir() else "file" + lines.append(f" {child_kind}: {(relative_root / child.name).as_posix()}") + + if not discovered: + lines.append(" inactive: No supported source directories found.") + + return "\n".join(lines) diff --git a/agents/skills/migrate-to-codex/scripts/utils/util.py b/agents/skills/migrate-to-codex/scripts/utils/util.py new file mode 100644 index 0000000..3be50ad --- /dev/null +++ b/agents/skills/migrate-to-codex/scripts/utils/util.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import glob +import json +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + + +def detected_json_keys(content: str, keys: Sequence[str]) -> tuple[str, ...]: + return tuple( + key + for key in keys + if re.search(rf'"{re.escape(key)}"\s*:', content) + ) + + +def strip_jsonc_comments(content: str) -> str: + lines: list[str] = [] + for line in content.splitlines(): + in_string = False + escaped = False + result: list[str] = [] + index = 0 + while index < len(line): + char = line[index] + if escaped: + result.append(char) + escaped = False + index += 1 + continue + if char == "\\" and in_string: + result.append(char) + escaped = True + index += 1 + continue + if char == '"': + in_string = not in_string + result.append(char) + index += 1 + continue + if not in_string and char == "/" and index + 1 < len(line) and line[index + 1] == "/": + break + result.append(char) + index += 1 + lines.append("".join(result)) + return "\n".join(lines) + + +def load_jsonc_object(content: str, json_object: callable) -> Mapping[str, object]: + without_comments = strip_jsonc_comments(content) + without_trailing_commas = re.sub(r",\s*([}\]])", r"\1", without_comments) + return json_object(json.loads(without_trailing_commas)) + + +def parse_jsonc_mapping_text(text: str) -> Mapping[str, object] | None: + """Return the top-level JSON object, or None if the text is not a JSON object.""" + try: + without_comments = strip_jsonc_comments(text) + without_trailing_commas = re.sub(r",\s*([}\]])", r"\1", without_comments) + parsed = json.loads(without_trailing_commas) + except (json.JSONDecodeError, TypeError, ValueError): + return None + if isinstance(parsed, Mapping): + return parsed + return None + + +@dataclass(frozen=True) +class JsonMappingFileRead: + exists: bool + ok: bool + data: Mapping[str, object] + + +def read_json_mapping_file(path: Path) -> JsonMappingFileRead: + """Read a JSON/JSONC file. ``ok`` is False when the file exists but could not be parsed.""" + if not path.is_file(): + return JsonMappingFileRead(exists=False, ok=True, data={}) + text = path.read_text() + parsed = parse_jsonc_mapping_text(text) + if parsed is None: + return JsonMappingFileRead(exists=True, ok=False, data={}) + return JsonMappingFileRead(exists=True, ok=True, data=parsed) + + +def slugify_name(value: str) -> str: + result = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip()).strip("-").lower() + return result or "migrated-command" + + +def first_markdown_heading(content: str) -> str | None: + for line in content.splitlines(): + match = re.match(r"^#\s+(.+?)\s*$", line) + if match: + return match.group(1).strip() + return None + + +def format_backtick_list(values: Sequence[str]) -> str: + if not values: + return "" + if len(values) == 1: + return f"`{values[0]}`" + return ", ".join(f"`{value}`" for value in values[:-1]) + f", and `{values[-1]}`" + + +def normalize_source_scope_root(path: Path, source_scope_markers: Sequence[Path]) -> Path: + resolved = path + for marker in source_scope_markers: + if resolved.parts[-len(marker.parts) :] == marker.parts: + return resolved.parents[len(marker.parts) - 1] + return resolved + + +def resolve_source_root(source: str) -> Path: + if not glob.has_magic(source): + return Path(source) + + matches = [Path(match) for match in glob.glob(source, recursive=True)] + if not matches: + raise FileNotFoundError(f"No matches for source pattern: {source}") + + for match in matches: + if match.is_dir() and (match / "global").exists() and (match / "project").exists(): + return match + + static_prefix = source.split("*", 1)[0].rstrip("/") + return Path(static_prefix) diff --git a/agents/skills/simple-html-artifact/SKILL.md b/agents/skills/simple-html-artifact/SKILL.md new file mode 100644 index 0000000..521b602 --- /dev/null +++ b/agents/skills/simple-html-artifact/SKILL.md @@ -0,0 +1,103 @@ +--- +name: simple-html-artifact +description: Build or refine single-file information-first HTML artifacts, especially index.html or text.html pages, with strong information hierarchy, restrained styling, accessible semantics, and minimal AI-generated frontend tells. Use when creating static HTML reports, research pages, explainers, briefs, dashboards, note indexes, or simple front ends whose goal is comprehension rather than marketing conversion. +--- + +# Simple HTML Artifact + +Use this for static, single-page HTML artifacts that explain, compare, summarize, or organize information. Default to one browser-openable `index.html` or `text.html` file with Tailwind CDN. Add JavaScript only for filtering, sorting, copying, disclosure, or keyboard-safe interaction. + +## Goal + +Optimize for comprehension, orientation, retrieval, and trust, not conversion. The first viewport must show real information, not only hero copy. + +Default priorities: +- Reading path: summary -> evidence -> reference. +- Claims paired with definitions, caveats, dates, assumptions, or sources when needed. +- Semantic HTML, readable type, mobile fit, keyboard order, print readability, and contrast. +- One visual decision tied to the subject or reader job. + +Avoid: +- Generic AI SaaS: gradient hero, glass cards, icon grids, abstract glows, repeated rounded cards, vague CTAs. +- Civic-document drift: beige/off-white page wash, low-contrast gray text, boxed nav chips, heavy dividers, bordered panels everywhere. +- Fake completeness: made-up metrics, arbitrary percentages, placeholder data, decorative charts, or visuals implying false precision. + +## Brief + +Before styling, decide: + +```text +Subject: +Audience/context: +Reader job: +Posture: +Primary surface: +Type direction: +Color direction: +Deviation reason, if any: +``` + +Carry forward explicit user preferences: information-first pages, no generic AI aesthetics, no line-heavy civic layouts, Tailwind by default, and `bg-white` by default. + +If editing an existing artifact, inspect it first and preserve its copy, IA, density, tokens, and primitives unless they are the defect. + +Use web search only when the subject is broad, current, culturally specific, or visually ambiguous. Look for comparable information artifacts, not marketing pages. Extract design logic: type role, density, color relationship, diagram language, label style, and the surface that carries the information. + +Choose a posture that constrains taste, such as editorial guide, field guide, research brief, technical reference, museum label, workshop handout, or operating checklist. No posture usually means generic SaaS or government-document drift. + +## Information Architecture + +Start with the reader job: +- **Orient**: purpose, audience, scope, main question. +- **Decide**: 3-5 key claims, rules, risks, rankings, or recommendations. +- **Inspect**: examples, excerpts, visuals, rows, scales, notes, or tables. +- **Retrieve**: anchors, labels, section names, compact references. + +Default order: title and purpose, useful metadata, concrete key points, one primary surface, supporting sections by reader need, caveats/method/sources near the relevant claims or near the end. + +For one-pagers, compose one artifact rather than reusable app sections. Add sticky nav, rails, or repeated section chrome only when page length requires navigation. + +## Surface Choice + +Do not default to tables, matrices, dashboards, or two columns. Pick by reader action: +- Compare identical attributes -> table or matrix. +- Choose among options -> decision tree, ranked strips, "choose this if", scorecard. +- Learn a taxonomy -> field-guide entries, specimen cards, annotated map, glossary. +- Understand sequence -> timeline, process diagram, checklist, flow. +- See relationships -> axis map, cluster diagram, annotated scale, small multiples. +- Remember rules -> rule cards, do/avoid pairs, recipe, cheat sheet. +- Monitor state -> compact dashboard with definitions. + +Use tables only when rows share attributes and column scanning beats prose. Use two columns only for paired relationships: overview/detail, map/list, before/after, controls/results, image/annotation, or claim/evidence. Use SVG only when it explains, locates, compares, encodes scale, or gives subject-specific identity. + +## Visual Defaults + +Keep the layout as narrow as the content allows: prose usually fits `max-w-3xl` to `max-w-4xl`; wide surfaces can use `max-w-5xl` to `max-w-6xl`. Keep prose around 65-80 characters per line. Use stable dimensions for fixed grids, charts, and controls. + +Use type, alignment, whitespace, tint, and composition before borders. Leave some modules unboxed. If most sections need lines, the hierarchy is flat. + +Default to `bg-white`. Use off-white, tinted, dark, or textured backgrounds only for user preference, brand/source palette, subject motif, print requirement, or hierarchy. Use Tailwind stock colors: dark text, muted text, light line, one accent scale, at most one supporting tint. Every added hue needs a job. + +Choose type by posture. Keep four roles at most: display, section heading, body, caption/label. Pair fonts by role contrast, not novelty. If loading external fonts, use at most two families and `font-display: swap`; otherwise use system `serif`, `sans`, and `mono` intentionally. + +## Content Discipline + +Set length by job: summaries use 3-5 concrete claims; table cells stay compact; diagram labels explain position or relationship; prose keeps one idea per paragraph; caveats and sources stay near claims they change. + +If a section is too long, convert prose into labels, rows, scales, captions, or do/avoid pairs. If too thin, add an example, comparison, caveat, definition, or decision rule. Do not add generic overview paragraphs to make the page feel complete. + +Mark assumptions, dates, freshness, scope, confidence, sources, and placeholder data when they affect interpretation. Do not invent metrics, ratings, examples, or benchmarks to fill a layout. Charts/SVGs need units, labels or legend, and a takeaway caption. + +## Tailwind Defaults + +Use Tailwind CDN: ``. Use Tailwind classes for layout/color/type/spacing/states. Use `