Add migrate-to-codex skill (#364)

**Summary**

- Adds the public `migrate-to-codex` curated skill for migrating
external agent setup into Codex-facing files.
- Includes the approved skill instructions, references, and migration
scripts from openai/skills-internal#84.
- Places the skill at `skills/.curated/migrate-to-codex`, which is the
installable public path the Codex App should reference.
This commit is contained in:
Nick Baumann
2026-04-27 13:29:29 -07:00
committed by GitHub
parent 724cd511c9
commit 728a3e9ba7
19 changed files with 3698 additions and 0 deletions
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+114
View File
@@ -0,0 +1,114 @@
---
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/`). Preserve unrelated existing Codex config entries in `.codex/config.toml` or `~/.codex/config.toml`, such as `notify`, `projects`, `marketplaces`, or unrelated MCP servers; do not ask about them unless they fail validation or directly conflict with the migration. Do not edit source Claude Code files (`.claude/`, `~/.claude/`, `.mcp.json`, or `.claude.json`), unrelated project code, secrets, or another repository.
## Migration Order
Run the migration in this order for each selected global or project source:
1. Start by using Codex's built-in TODO/task list tool. Do not create `MIGRATION_TODOS.md` or any TODO file unless the user explicitly asks. The TODO list input has a `plan` array whose items each have `step` and `status`; use statuses `pending`, `in_progress`, and `completed`. Make the TODOs specific to the selected artifacts. Use literal source → Codex target labels, for example:
- Inspect `.claude/commands` → Codex skills/prompts
- Inspect `.claude/agents``.codex/agents`
- Inspect `.mcp.json``.codex/config.toml` MCP servers
- Inspect `.claude/settings.json` hooks → `.codex/hooks.json`
- Migrate safe selected artifacts → Codex files
- Validate generated `.codex/config.toml`
- Validate generated `.codex/agents`
- Report migrated artifacts and manual-review items
2. Read `references/differences.md` (and refresh Codex docs if its `Docs last checked` date is old).
3. Scan and inspect before writing:
- `--scan-only` lists active and inactive source surfaces.
- `--plan` prints staged Codex artifact paths and report rows.
- `--doctor` summarizes readiness, manual-review work, and validation risks.
4. Convert surfaces in the same order the CLI uses:
- instructions: `CLAUDE.md` / `AGENTS.md` to `AGENTS.md`
- plugins: report Claude plugin trees and marketplaces as manual migration work
- hooks: rewrite supported Claude hooks into `.codex/hooks.json` and enable `[features].codex_hooks = true`
- skills and commands: write Codex skills under `.agents/skills/`
- config: write `.codex/config.toml` from Claude model/sandbox settings and MCP servers, including `personality = "friendly"` when config is generated
- subagents: write Codex custom agents under `.codex/agents/`
5. Dry-run, then write the selected target. Use `--replace` only when orphan generated skills or agents should be deleted.
6. Inspect the terminal output and `.codex/migrate-to-codex-report.txt` after real runs.
7. Review generated artifacts in this order: `AGENTS.md`, `.agents/skills/`, `.codex/config.toml`, `.codex/hooks.json`, `.codex/agents/`, then report-only plugin items.
8. Run `--validate-target` against each target after edits.
9. Re-run checks and `--dry-run` after edits.
10. Return the final migration report as one markdown table per scope that has rows. The tables cover only the non-native follow-up migration work you performed, such as skills created from slash commands, subagents, MCP servers, hooks, unsupported/local plugin notes, and manual-review caveats. Include programmatic native import rows for config, instructions, skills, or supported plugins only if you personally migrated them in this follow-up run.
If only one scope has rows, render only the table with no heading. If multiple scopes have rows, render one heading before each table. Use `**User Config**` for user-scope rows. For project-scope rows, use the actual project folder name as the heading, for example `**northstar-support-portal**`; do not use `Current Project` as the heading. Do not add prose before or after the table output.
Use exactly these columns:
**northstar-support-portal**
| Status | Item | Notes |
| --- | --- | --- |
| `Added` | `Slash command` pr-review | Converted into a Codex skill |
| `Added` | `Subagent` release-lead | Added as a Codex subagent |
| `Check before using` | `Hook` PreToolUse | Converted, but some Claude hook behavior differs in Codex |
| `Not Added` | `Hook` Notification | Codex does not have an equivalent notification hook |
| `Not Added` | `Plugin` team-macros | Plugin needs manual setup |
`Status` must be `Added`, `Check before using`, or `Not Added`. Use `Added` when a Codex-facing artifact was created or changed and needs no special review. Use `Check before using` when a Codex-facing artifact was created or changed but the migration changed semantics, inferred behavior, preserved tool rules as guidance, or dropped unsupported behavior. Use `Not Added` when a source artifact was detected but no Codex-facing artifact was created. `Item` combines the artifact type and concrete item name in one cell. Artifact type must be singular: `Skill`, `Slash command`, `Subagent`, `MCP`, `Hook`, or `Plugin`. Wrap the artifact type in inline code; write the item name as plain text after it. `Notes` is always required; never leave it empty. Keep notes short, plain, and literal. Avoid internal implementation terms such as runtime expansion. Prefer phrases like `Converted into a Codex skill`, `Added as a Codex subagent`, `Added to Codex config`, `Converted into a Codex hook`, `Converted, but some Claude hook behavior differs in Codex`, `Codex does not have an equivalent notification hook`, `Plugin needs manual setup`, or `Plugin marketplace needs manual setup`.
## Self-Healing Loop
Keep looping until the selected migration is complete:
1. Run `--plan` or `--doctor`.
2. Run the migration with `--dry-run`.
3. Run the migration for real.
4. Fix every generated `## MANUAL MIGRATION REQUIRED` block and every `manual_fix_required` or `skipped` report row that can be resolved inside Codex artifacts.
5. Run `--validate-target`.
6. Re-run the migrator and validator until the report and validator have no actionable generated-artifact fixes left.
Do not edit source Claude Code files, unrelated project code, secrets, or another repository during this loop. If a report row requires source-provider changes or product judgment, leave the generated Codex artifact with clear manual guidance instead of changing the source.
## Commands
Choose the migrator command.
```bash
MIGRATE_TO_CODEX='python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py'
```
Inspect the migration before writing.
```bash
$MIGRATE_TO_CODEX --source ~/.claude/ --scan-only
$MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --plan
$MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --doctor
```
Dry-run, then run without `--dry-run`, for global and project.
```bash
$MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --dry-run
$MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/
$MIGRATE_TO_CODEX --source ./.claude/ --target ./.codex/ --dry-run
$MIGRATE_TO_CODEX --source ./.claude/ --target ./.codex/
```
Run the post-migration validator against each target after edits.
```bash
$MIGRATE_TO_CODEX --validate-target ~/.codex/
$MIGRATE_TO_CODEX --validate-target ./.codex/
```
Run `$MIGRATE_TO_CODEX --help` for flags (`--scan-only`, `--plan`, `--doctor`, `--validate-target`, defaults, and so on). Deep tables and more links are in `references/differences.md`.
@@ -0,0 +1,3 @@
interface:
display_name: "Migrate to Codex"
short_description: "Migrate supported instruction files, skills, agents, and config into Codex"
@@ -0,0 +1,167 @@
# Migration Differences
## Summary
This reference covers Claude Code to Codex migration only. It lists migration differences, partial mappings, and unsupported Claude Code behavior. Direct 1:1 mappings are intentionally omitted. When the converter preserves Claude-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-20. If today's date is later, re-open the official Codex docs below and the Claude Code docs map before trusting these mappings.
## Instructions
| Source | Codex | Migration behavior | Caveat |
| --- | --- | --- | --- |
| `.claude/CLAUDE.md`, `CLAUDE.md`, or `claude.md` | `AGENTS.md` symlink | Linked automatically when content looks provider-neutral | This keeps one shared instruction body instead of duplicating docs. |
| 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`, `.claude/agents/`, settings paths, subagent language, or permission-mode assumptions | Generated `AGENTS.md` copy | Manual rewrite pass | The converter intentionally breaks the symlink when obvious Claude-only semantics need a Codex-specific edit. |
## Commands
| Source | Codex | Migration behavior | Caveat |
| --- | --- | --- | --- |
| `.claude/commands/*.md` | `.agents/skills/source-command-<name>/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. |
| Command files 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 hook behavior have different runtime behavior and must be checked manually. |
## Skills
| Source | Codex | Migration behavior | Caveat |
| --- | --- | --- | --- |
| `.claude/skills/<name>/SKILL.md` | `.agents/skills/<name>/SKILL.md` | Converted; selected support directories are copied | Skill-local `scripts/`, `references/`, and `assets/` are copied when they are real files under the source skill root. |
| `.claude/skills/<name>.md` | `.agents/skills/<name>/SKILL.md` | Converted as a single-file skill | No sibling support directories are copied for this legacy shape. |
| `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 |
| --- | --- | --- | --- |
| `.mcp.json` or `.claude.json` `mcpServers` | `.codex/config.toml` `[mcp_servers.<name>]` | Converted | Project `.mcp.json` and global `.claude.json` use the same source shape for this migrator. Codex supports additional MCP server fields such as `cwd`, `enabled_tools`, `disabled_tools`, and timeout settings, but this converter only writes fields that map clearly from Claude source config. |
| Claude Code model/sandbox settings or MCP config | `personality = "friendly"` | Written when the migrator generates `.codex/config.toml` | Codex supports `none`, `friendly`, and `pragmatic`; Claude Code migrations default to friendly to preserve a warm assistant style. |
| `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` | Manual review only | `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 |
| --- | --- | --- | --- |
| `.claude/agents/*.md` | `.codex/agents/*.toml` | Converted | Missing `name` or `description` is inferred and reported for review. |
| `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` | Codex custom-agent `mcp_servers` or shared Codex MCP config | Manual review only | Codex custom-agent files can include MCP config, but this converter does not automatically map Claude subagent `mcpServers`. Use shared Codex MCP config or manually add agent-local `mcp_servers` when the source intent is clear. |
| `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 Claude 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 Marketplaces
| Source | Codex | Migration behavior | Caveat |
| --- | --- | --- | --- |
| `.claude/plugins/` | Codex plugins / skills / MCP servers / apps | Reported as `manual_fix_required` only | Codex plugins can bundle skills, MCP servers, and apps, but the migrator does not copy plugin trees. Migrate the plugin, bundled skills, commands, agents, hooks, and MCP config by hand. |
| `.claude/plugin-marketplaces.json` | Codex plugin install or local plugin path | Reported as `manual_fix_required` only | Marketplace entries can point to local or remote plugin sources; the migrator does not fetch or install them. Codex marketplace metadata lives under `.agents/plugins/marketplace.json` or `~/.agents/plugins/marketplace.json`. |
| `.claude-plugin/marketplace.json` | Codex plugin install or local plugin path | Reported as `manual_fix_required` only | Treat it as marketplace source material. Do not copy it into Codex as a legacy marketplace; adapt it to the Codex plugin marketplace layout if you keep it local. |
| `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 | Codex plugin manifest and bundled skill paths | Manual review only | Codex plugins can declare bundled skills, MCP servers, and apps. Custom Claude plugin paths still need manual layout review; no automated scan. |
| Plugin `commands/` | `.agents/skills/<name>/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. |
## 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` | Partial conversion | Review behavior before relying on migrated hooks; Claude and Codex hook runtimes are not 1:1. |
| `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` | Partial conversion | 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` | Partial conversion | 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` | Partial conversion | 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` | Partial conversion | Codex matches `startup` and `resume`; Claude may also expose other session flows. |
| `Stop` | `Stop` in `.codex/hooks.json` | Partial conversion | 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` / `PreCompact` / `PostCompact` / `SessionEnd` | No direct equivalent | Unsupported | Keep as manual follow-up items; Codex does not expose matching lifecycle coverage today. |
| `type: "command"` | `type: "command"` | Partial conversion | `command`, `timeout` / `timeoutSec`, and `statusMessage` map. 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 | Partial conversion | Codex keeps regex `matcher` for `PreToolUse`, `PostToolUse`, and `SessionStart` only. 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. |
## Planning and validation
| Command | Behavior | Caveat |
| --- | --- | --- |
| `--plan` | Prints staged migration output and generated artifact paths without writing files | Still depends on the selected source, target, and component flags. |
| `--doctor` | Prints readiness, risk counts, and manual-review items without writing files | Static guidance only; it does not prove the migrated setup works. |
| `--validate-target` | Validates an already migrated Codex target | Checks TOML parseability, skill frontmatter, custom-agent TOML fields, AGENTS.md size, and MCP command availability. |
## Minimal examples
Source skill metadata becomes prompt guidance:
```md
allowed-tools:
- Read
- Bash
```
```md
## MANUAL MIGRATION REQUIRED
Claude `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
@@ -0,0 +1,907 @@
"""CLI orchestration for migrate-to-codex.
This module owns argument parsing, scan/dry-run/report rendering, deployment
planning, and file writes. Provider-specific conversions live in
`migrate.<surface>` modules; keep this file as the coordinator that combines
instruction, skill, MCP, hook, plugin-report, and subagent conversion results.
"""
from __future__ import annotations
import argparse
import os
import shutil
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from migrate.agents import (
AGENT_SOURCE_ROOTS,
convert_agents,
iter_agent_files,
validate_agent_files,
)
from migrate.common import (
CODEX_AGENTS_ROOT,
CODEX_SKILLS_ROOT,
ArtifactKind,
ArtifactPayload,
ConversionResult,
GeneratedText,
MigrationReportItem,
MigrationSummary,
PlannedArtifact,
ScopePaths,
SourceCopy,
SourceSymlink,
format_manual_migration_block,
path_exists_with_exact_case,
)
from migrate.hooks import report_hooks
from migrate.codex_config import convert_settings, validate_config_toml
from migrate.instructions import (
INSTRUCTION_SOURCE_CANDIDATES,
instruction_source_file,
should_symlink_instructions,
validate_agents_md_files,
)
from migrate.plugins import report_plugins
from migrate.settings import SOURCE_SCAN_ROOTS, SOURCE_SCOPE_MARKERS
from migrate.skills import (
COMMAND_FILE_SOURCES,
SKILL_SOURCE_ROOTS,
convert_skills,
iter_skill_files,
validate_skill_files,
)
from utils.scan import (
render_scope_inventory,
render_source_inventory,
)
from utils.util import normalize_source_scope_root, resolve_source_root
# Constants
DEFAULT_COMPONENTS = frozenset(("mcp", "skills", "subagents"))
MIGRATION_REPORT_PATH = Path(".codex") / "migrate-to-codex-report.txt"
SCOPE_NAMES = ("global", "project")
SKILL_ROOT = Path(__file__).resolve().parents[1]
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_messages(self) -> tuple[str, ...]:
return tuple(
[
*(
f"warning: overwriting existing Codex skill at {collision}"
for collision in self.colliding_skill_dirs
),
*(
f"warning: overwriting existing Codex subagent at {collision}"
for collision in self.colliding_agent_files
),
]
)
@dataclass(frozen=True)
class ScopeDeployment:
artifacts: tuple[PlannedArtifact, ...]
target_root: Path
components: frozenset[str] = DEFAULT_COMPONENTS
def planned_paths(self, artifact_kind: ArtifactKind) -> frozenset[Path]:
return frozenset(
artifact.relative_path.parent
if artifact_kind == ArtifactKind.SKILL
else artifact.relative_path
for artifact in self.artifacts
if artifact.kind == artifact_kind
)
def existing_paths(self, root: Path, pattern: str) -> list[Path]:
if not root.exists():
return []
return sorted(path for path in root.glob(pattern) if path.is_dir() or path.is_file())
def orphaned_codex_paths(
self,
component: str,
artifact_kind: ArtifactKind,
codex_root: Path,
pattern: str,
) -> list[Path]:
if component not in self.components:
return []
target_root = self.target_root / codex_root
planned_paths = self.planned_paths(artifact_kind)
orphans: list[Path] = []
for target_path in self.existing_paths(target_root, pattern):
relative_path = codex_root / target_path.name
if relative_path not in planned_paths:
orphans.append(target_path)
return orphans
def colliding_codex_paths(
self,
component: str,
artifact_kind: ArtifactKind,
codex_root: Path,
) -> list[Path]:
if component not in self.components or not (self.target_root / codex_root).exists():
return []
collisions: list[Path] = []
for relative_path in self.planned_paths(artifact_kind):
target_path = self.target_root / relative_path
if target_path.exists():
collisions.append(target_path)
return collisions
def plan(self) -> DeploymentPlan:
orphaned_skill_dirs = tuple(
self.orphaned_codex_paths(
"skills",
ArtifactKind.SKILL,
CODEX_SKILLS_ROOT,
"*",
)
)
orphaned_agent_files = tuple(
self.orphaned_codex_paths(
"subagents",
ArtifactKind.AGENT,
CODEX_AGENTS_ROOT,
"*.toml",
)
)
colliding_skill_dirs = tuple(
self.colliding_codex_paths(
"skills",
ArtifactKind.SKILL,
CODEX_SKILLS_ROOT,
)
)
colliding_agent_files = tuple(
self.colliding_codex_paths(
"subagents",
ArtifactKind.AGENT,
CODEX_AGENTS_ROOT,
)
)
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),
),
)
@dataclass(frozen=True)
class MigrationContext:
conversion_result: ConversionResult
deployment_plan: DeploymentPlan
deployment_target_root: Path
# 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_plugins(scope))
result.add(report_hooks(scope))
if "skills" in components:
result.add(convert_skills(scope.source))
if "mcp" in components:
result.add(convert_settings(scope))
if "subagents" in components:
result.add(convert_agents(scope.source))
return result
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{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 symlink_target(source_path: Path, target_path: Path) -> str:
return os.path.relpath(source_path, target_path.parent)
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 validate_target(target_root: Path) -> list[MigrationReportItem]:
report_items: list[MigrationReportItem] = []
report_items.extend(validate_config_toml(target_root))
report_items.extend(validate_skill_files(target_root))
report_items.extend(validate_agent_files(target_root))
report_items.extend(validate_agents_md_files(target_root))
return report_items
def render_validation_report(report_items: list[MigrationReportItem]) -> str:
lines = ["Validation report:"]
if not report_items:
lines.append(" warning: . - no Codex artifacts found to validate.")
return "\n".join(lines)
for item in report_items:
lines.append(f" {item.status}: {item.path.as_posix()} - {item.detail}")
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")
def write_artifact(artifact: PlannedArtifact, target_root: Path) -> None:
target_path = target_root / artifact.relative_path
target_path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(artifact.payload, GeneratedText):
if target_path.is_symlink():
target_path.unlink()
target_path.write_text(artifact.payload.content)
return
if isinstance(artifact.payload, SourceSymlink):
if target_path.exists() or target_path.is_symlink():
target_path.unlink()
target_path.symlink_to(symlink_target(artifact.payload.source_path, target_path))
return
if target_path.is_symlink():
target_path.unlink()
shutil.copy2(artifact.payload.source_path, target_path)
# 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 render_migration_inventory(source_root: Path) -> str:
return render_scope_inventory(
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,
)
def render_source_inventory_for_scope(source_root: Path) -> str:
return render_source_inventory(
source_root,
SOURCE_SCAN_ROOTS,
path_exists_with_exact_case,
)
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)
def build_migration_context(
source_root: Path,
target_root: Path,
components: frozenset[str],
) -> MigrationContext:
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,
)
return MigrationContext(
conversion_result,
deployment_plan,
deployment_target_root,
)
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()
return MigrationContext(conversion_result, deployment_plan, deployment_target_root)
def render_migration_plan(
conversion_result: ConversionResult,
deployment_plan: DeploymentPlan,
deploy_mode: DeployMode,
) -> str:
lines = [
"Migration plan:",
f" deploy mode: {deploy_mode.value}",
]
summary = conversion_result.summary
if summary.instructions:
lines.append(f" stage: instructions - {summary.instructions} AGENTS.md file(s).")
if summary.mcp_servers:
lines.append(f" stage: mcp - {summary.mcp_servers} MCP server(s).")
if summary.skills:
lines.append(f" stage: skills - {summary.skills} Codex skill artifact(s).")
if summary.subagents:
lines.append(f" stage: subagents - {summary.subagents} Codex custom agent(s).")
if not any(
(
summary.instructions,
summary.mcp_servers,
summary.skills,
summary.subagents,
)
):
lines.append(" stage: none - no supported migration surfaces found.")
if conversion_result.artifacts:
lines.append(" artifacts:")
for artifact in sorted(
conversion_result.artifacts,
key=lambda planned: planned.relative_path.as_posix(),
):
lines.append(f" - {artifact.relative_path.as_posix()}")
manual_items = [
item
for item in conversion_result.report_items
if item.status == "manual_fix_required"
]
if manual_items:
lines.append(f" manual review: {len(manual_items)} item(s)")
for item in manual_items:
lines.append(f" - {item.path.as_posix()}: {item.detail}")
else:
lines.append(" manual review: none")
if deployment_plan.colliding_skill_dirs or deployment_plan.colliding_agent_files:
lines.append(" collisions:")
for collision in deployment_plan.colliding_skill_dirs:
lines.append(f" - existing skill: {collision.as_posix()}")
for collision in deployment_plan.colliding_agent_files:
lines.append(f" - existing subagent: {collision.as_posix()}")
if deployment_plan.orphaned_skill_dirs or deployment_plan.orphaned_agent_files:
lines.append(" orphan cleanup:")
for orphan in deployment_plan.orphaned_skill_dirs:
lines.append(f" - skill: {orphan.as_posix()}")
for orphan in deployment_plan.orphaned_agent_files:
lines.append(f" - subagent: {orphan.as_posix()}")
return "\n".join(lines)
def render_doctor_report(
conversion_result: ConversionResult,
deployment_plan: DeploymentPlan,
) -> str:
manual_items = [
item
for item in conversion_result.report_items
if item.status == "manual_fix_required"
]
collision_count = len(deployment_plan.colliding_skill_dirs) + len(
deployment_plan.colliding_agent_files
)
orphan_count = len(deployment_plan.orphaned_skill_dirs) + len(
deployment_plan.orphaned_agent_files
)
risk_count = len(manual_items) + collision_count + orphan_count
if risk_count == 0:
readiness = "high"
elif risk_count <= 3:
readiness = "medium"
else:
readiness = "low"
lines = [
"Migration doctor:",
f" readiness: {readiness}",
f" manual review items: {len(manual_items)}",
f" existing Codex collisions: {collision_count}",
f" orphaned generated artifacts: {orphan_count}",
]
if manual_items:
lines.append(" risks:")
for item in manual_items:
lines.append(f" - {item.path.as_posix()}: {item.detail}")
else:
lines.append(" risks: none detected by static migration checks.")
lines.append(" recommended flow: run --plan, run --dry-run, review manual items, migrate, then run --validate-target.")
return "\n".join(lines)
# 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",
help="Source root (optional global/ + project/ subdirs).",
)
parser.add_argument(
"--target",
help="Codex root (required for migrate, --plan, and --doctor).",
)
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.",
)
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
"--scan-only", action="store_true", help="Inventory only; omit --target."
)
mode_group.add_argument(
"--plan",
action="store_true",
help="Print staged migration plan; do not write files.",
)
mode_group.add_argument(
"--doctor",
action="store_true",
help="Print readiness and manual-review guidance; do not write files.",
)
mode_group.add_argument(
"--validate-target",
help="Validate an existing migrated Codex target and exit.",
)
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()
if args.validate_target:
target_root = normalize_scope_root(Path(args.validate_target), ".codex")
report_items = validate_target(target_root)
print(render_validation_report(report_items))
if any(item.status == "error" for item in report_items):
raise SystemExit(1)
return
if not args.source:
parser.error("--source is required unless --validate-target is set.")
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 or --validate-target is set.")
if args.scan_only:
if (source_root / "global").exists() and (source_root / "project").exists():
print(render_source_inventory_for_scope(source_root / "global"))
print(render_migration_inventory(source_root / "global"))
print(render_source_inventory_for_scope(source_root / "project"))
print(render_migration_inventory(source_root / "project"))
else:
normalized_source_root = normalize_source_scope_root(
source_root,
SOURCE_SCOPE_MARKERS,
)
print(render_source_inventory_for_scope(normalized_source_root))
print(render_migration_inventory(normalized_source_root))
return
target_root = Path(args.target)
components = selected_components(args)
deploy_mode = DeployMode.REPLACE if args.replace else DeployMode.MERGE
context = build_migration_context(source_root, target_root, components)
conversion_result = context.conversion_result
deployment_plan = context.deployment_plan
deployment_target_root = context.deployment_target_root
conversion_result.summary.add(deployment_plan.summary)
if args.plan:
print(render_migration_plan(conversion_result, deployment_plan, deploy_mode))
return
if args.doctor:
print(render_doctor_report(conversion_result, deployment_plan))
return
source_inventory = ""
migration_inventory = ""
if args.scan_sources:
if (source_root / "global").exists() and (source_root / "project").exists():
source_inventory = (
render_source_inventory_for_scope(source_root / "global")
+ "\n"
+ render_source_inventory_for_scope(source_root / "project")
)
else:
source_inventory = render_source_inventory_for_scope(
normalize_source_scope_root(source_root, SOURCE_SCOPE_MARKERS),
)
if (source_root / "global").exists() and (source_root / "project").exists():
migration_inventory = (
render_migration_inventory(source_root / "global")
+ "\n"
+ render_migration_inventory(source_root / "project")
)
else:
migration_inventory = render_migration_inventory(
normalize_source_scope_root(source_root, SOURCE_SCOPE_MARKERS),
)
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 warning_message in deployment_plan.warning_messages():
print(warning_message, file=sys.stderr)
if not args.dry_run:
for artifact in deployment_plan.artifacts:
write_artifact(artifact, deployment_target_root)
if deploy_mode == DeployMode.REPLACE:
for orphan in deployment_plan.orphaned_skill_dirs:
shutil.rmtree(orphan)
for orphan in deployment_plan.orphaned_agent_files:
orphan.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()
@@ -0,0 +1,12 @@
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from cli import * # noqa: F403,E402
from cli import main # noqa: E402
if __name__ == "__main__":
main()
@@ -0,0 +1,6 @@
"""Section-level migration code for migrate-to-codex.
Each module owns one source-to-Codex surface: instructions, skills/commands,
subagents, MCP/config, hooks, plugin-like sources, or shared primitives.
`cli.py` should orchestrate these modules instead of embedding conversions.
"""
@@ -0,0 +1,326 @@
"""Convert Claude Code subagents into Codex custom-agent TOML.
Reads `.claude/agents/*.md` files, parses their frontmatter/body, and emits
`.codex/agents/<name>.toml` artifacts. Partially mapped metadata such as
skills, tool allowlists, and unknown permission modes is preserved as prompt
guidance plus a manual-review report row.
"""
from __future__ import annotations
import tomllib
from pathlib import Path
from migrate.common import (
CODEX_AGENTS_ROOT,
ConversionResult,
MigrationReportItem,
ParsedDocument,
PlannedArtifact,
append_report_item,
format_bullets,
format_manual_migration_block,
map_model_effort,
map_model_name,
map_permission_mode,
unsupported_frontmatter_fields,
)
from utils.util import (
TomlMultilineString,
first_markdown_heading,
render_toml_document,
slugify_name,
)
AGENT_SOURCE_ROOTS = (
Path(".claude") / "agents",
)
SUPPORTED_AGENT_FRONTMATTER = (
"name",
"description",
"model",
"permissionMode",
"skills",
"tools",
"disallowedTools",
"effort",
)
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"
)
def agent_metadata(source_file: Path, document: ParsedDocument) -> dict[str, object]:
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 {
"name": name,
"description": description,
"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(),
SUPPORTED_AGENT_FRONTMATTER,
)
+ tuple(inferred_fields),
}
def convert_agent_file(source_file: Path) -> tuple[PlannedArtifact, MigrationReportItem]:
document = ParsedDocument.from_file(source_file)
metadata = agent_metadata(source_file, document)
artifact = PlannedArtifact.for_agent(
source_file,
render_agent_toml(document.body, **metadata),
)
return artifact, agent_report_item(source_file, **metadata)
def render_agent_toml(
body: str,
*,
name: str,
description: str,
model: str | None,
permission_mode: str | None,
skills: tuple[str, ...],
tools: tuple[str, ...],
disallowed_tools: tuple[str, ...],
effort: str | None,
unsupported_fields: tuple[str, ...],
) -> str:
document = {
"name": name,
"description": description,
}
if model:
document["model"] = map_model_name(model)
if effort:
document["model_reasoning_effort"] = map_model_effort(model, effort)
sandbox_mode = map_permission_mode(permission_mode)
if sandbox_mode:
document["sandbox_mode"] = sandbox_mode
document["developer_instructions"] = TomlMultilineString(
render_agent_body(
body,
permission_mode=permission_mode,
skills=skills,
tools=tools,
disallowed_tools=disallowed_tools,
unsupported_fields=unsupported_fields,
).strip()
)
return render_toml_document(document)
def render_agent_body(
body: str,
*,
permission_mode: str | None,
skills: tuple[str, ...],
tools: tuple[str, ...],
disallowed_tools: tuple[str, ...],
unsupported_fields: tuple[str, ...],
) -> str:
sections = []
manual_notes: list[str] = []
sandbox_mode = map_permission_mode(permission_mode)
if permission_mode and not sandbox_mode:
manual_notes.append(
f"Claude `permissionMode: {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 skills:
sections.append(
"## Skills\n\n"
"You're allowed to use these skills when working on this task:\n\n"
f"{format_bullets(skills, '$')}"
)
manual_notes.append(
"Claude `skills` preload semantics were preserved as prompt guidance. Verify this agent still discovers the intended skills at runtime."
)
if tools or disallowed_tools:
tool_section_lines = [
"## Tools",
"",
"Claude tool allow/deny lists were preserved as prompt guidance, not Codex permissions.",
]
if tools:
tool_section_lines.extend(
[
"",
"You're allowed to use these tools:",
"",
format_bullets(tools),
]
)
if disallowed_tools:
tool_section_lines.extend(
[
"",
"Don't use these tools:",
"",
format_bullets(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 unsupported_fields:
manual_notes.append(
"Review unsupported Claude subagent fields manually: "
f"{', '.join(f'`{field_name}`' for field_name in unsupported_fields)}."
)
if manual_notes:
sections.append(format_manual_migration_block(manual_notes))
if not sections:
return body
joined_sections = "\n\n".join(sections)
return f"{body.rstrip()}\n\n{joined_sections}\n"
def agent_report_detail(
*,
permission_mode: str | None,
skills: tuple[str, ...],
tools: tuple[str, ...],
disallowed_tools: tuple[str, ...],
unsupported_fields: tuple[str, ...],
**_: object,
) -> str:
caveats: list[str] = []
if skills:
caveats.append("skills")
if tools:
caveats.append("tools")
if disallowed_tools:
caveats.append("disallowedTools")
if permission_mode and not map_permission_mode(permission_mode):
caveats.append("permissionMode")
caveats.extend(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)
+ "."
)
def agent_report_item(
source_file: Path,
*,
permission_mode: str | None,
skills: tuple[str, ...],
tools: tuple[str, ...],
disallowed_tools: tuple[str, ...],
unsupported_fields: tuple[str, ...],
**metadata: object,
) -> MigrationReportItem:
report_items: list[MigrationReportItem] = []
detail = agent_report_detail(
permission_mode=permission_mode,
skills=skills,
tools=tools,
disallowed_tools=disallowed_tools,
unsupported_fields=unsupported_fields,
**metadata,
)
append_report_item(
report_items,
skills
or tools
or disallowed_tools
or (permission_mode and not map_permission_mode(permission_mode))
or unsupported_fields,
CODEX_AGENTS_ROOT / f"{source_file.stem}.toml",
detail,
detail,
)
return report_items[0]
def convert_agents(source_root: Path) -> ConversionResult:
return convert_agent_files(source_root / ".claude" / "agents")
def convert_agent_files(source_root: Path) -> ConversionResult:
result = ConversionResult()
for source_file in iter_agent_files(source_root):
artifact, report_item = convert_agent_file(source_file)
result.artifacts.append(artifact)
result.summary.subagents += 1
result.report_items.append(report_item)
return result
def validate_agent_files(target_root: Path) -> list[MigrationReportItem]:
agents_root = target_root / CODEX_AGENTS_ROOT
if not agents_root.exists():
return []
report_items: list[MigrationReportItem] = []
for agent_file in sorted(agents_root.glob("*.toml")):
relative_path = agent_file.relative_to(target_root)
try:
parsed = tomllib.loads(agent_file.read_text())
except tomllib.TOMLDecodeError as exc:
report_items.append(
MigrationReportItem("error", relative_path, f"invalid TOML: {exc}.")
)
continue
missing = [
key
for key in ("name", "description", "developer_instructions")
if not parsed.get(key)
]
if missing:
report_items.append(
MigrationReportItem(
"error",
relative_path,
"agent TOML missing " + ", ".join(missing) + ".",
)
)
continue
report_items.append(
MigrationReportItem("ok", relative_path, "agent TOML has required fields.")
)
return report_items
@@ -0,0 +1,134 @@
"""Render Codex config from Claude Code settings and MCP inputs.
This module owns `.codex/config.toml` generation. It reads Claude Code
settings for model/sandbox equivalents, asks `mcps.py` for MCP server tables,
and adds Codex-native defaults that are not MCP-specific, such as the friendly
personality used for Claude Code migrations.
"""
from __future__ import annotations
import tomllib
from pathlib import Path
from migrate.common import (
CODEX_CONFIG_PATH,
ConversionResult,
GeneratedText,
MigrationReportItem,
MigrationSummary,
PlannedArtifact,
ScopePaths,
json_string,
json_string_tuple,
load_scope_settings,
map_model_name,
map_permission_mode,
)
from migrate.hooks import has_convertible_hooks
from migrate.mcps import (
mcp_report_items,
mcp_server_toml_table,
read_claude_mcp_servers,
validate_mcp_commands,
)
from utils.util import TomlValue, render_toml_document
DEFAULT_CODEX_PERSONALITY = "friendly"
def convert_settings(scope: ScopePaths) -> ConversionResult:
settings = load_scope_settings(scope.source)
mcp_servers = read_claude_mcp_servers(scope.source)
if not settings and not mcp_servers:
return ConversionResult()
enabled_mcp_servers = json_string_tuple(settings.get("enabledMcpjsonServers"))
disabled_mcp_servers = frozenset(
json_string_tuple(settings.get("disabledMcpjsonServers"))
)
config_toml = render_codex_config(
model=json_string(settings.get("model")),
permission_mode=json_string(settings.get("permissionMode")),
enabled_mcp_servers=enabled_mcp_servers,
disabled_mcp_servers=disabled_mcp_servers,
mcp_servers=mcp_servers,
codex_hooks_enabled=has_convertible_hooks(scope.source),
)
if not config_toml.strip():
return ConversionResult()
return ConversionResult(
summary=MigrationSummary(mcp_servers=len(mcp_servers)),
artifacts=[
PlannedArtifact(
relative_path=CODEX_CONFIG_PATH,
payload=GeneratedText(config_toml),
)
],
report_items=mcp_report_items(mcp_servers),
)
def render_codex_config(
model: str | None,
permission_mode: str | None,
enabled_mcp_servers: tuple[str, ...],
disabled_mcp_servers: frozenset[str],
mcp_servers: tuple[tuple[str, dict[str, object]], ...],
codex_hooks_enabled: bool,
) -> str:
document: dict[str, TomlValue] = {}
if model:
document["model"] = map_model_name(model)
sandbox_mode = map_permission_mode(permission_mode)
if sandbox_mode:
document["sandbox_mode"] = sandbox_mode
if mcp_servers:
document["mcp_servers"] = {
server_name: mcp_server_toml_table(
server_name,
server_config,
enabled_mcp_servers,
disabled_mcp_servers,
)
for server_name, server_config in mcp_servers
}
if codex_hooks_enabled:
document["features"] = {"codex_hooks": True}
if document:
document = {"personality": DEFAULT_CODEX_PERSONALITY, **document}
return render_toml_document(document)
def validate_config_toml(target_root: Path) -> list[MigrationReportItem]:
config_path = target_root / CODEX_CONFIG_PATH
if not config_path.exists():
return [
MigrationReportItem(
"warning",
CODEX_CONFIG_PATH,
"not present; no Codex config to validate.",
)
]
try:
parsed = tomllib.loads(config_path.read_text())
except tomllib.TOMLDecodeError as exc:
return [
MigrationReportItem(
"error",
CODEX_CONFIG_PATH,
f"invalid TOML: {exc}.",
)
]
report_items = [
MigrationReportItem("ok", CODEX_CONFIG_PATH, "valid TOML."),
]
report_items.extend(validate_mcp_commands(parsed))
return report_items
@@ -0,0 +1,401 @@
"""Shared data models, frontmatter rendering, reporting, and path helpers.
Defines the artifact/report model used by every migration section, the
YAML-frontmatter adapter used for skills/agents/commands, Claude-model and
permission-mode partial mappings, and generic filesystem/report helpers. This
module should not know about one migration surface's control flow.
"""
from __future__ import annotations
import re
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.settings import CLAUDE_SETTINGS_JSON_RELATIVE
from utils.util import (
format_yaml_mapping,
parse_yaml_mapping,
read_json_mapping_file,
)
FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.S)
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",
}
PERMISSION_MODE_MAPPINGS = {
"acceptEdits": "workspace-write",
"readOnly": "read-only",
}
YamlScalar: TypeAlias = str | bool | int | float | None
YamlValue: TypeAlias = YamlScalar | Sequence[YamlScalar]
@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 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 value is None:
return ()
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return tuple(str(item).strip() for item in value if str(item).strip())
return tuple(
split_item
for split_item in (part.strip() for part in str(value).split(","))
if split_item
)
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)
@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,
)
@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: object, dry_run: bool) -> str:
suffix = " (dry-run)" if dry_run else ""
deploy_mode_value = getattr(deploy_mode, "value", str(deploy_mode))
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)
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
],
)
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_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)
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 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 is_path_within_root(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
except ValueError:
return False
return True
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_yaml_frontmatter(raw_frontmatter, path), body, path)
def parse_yaml_frontmatter(
content: str,
path: Path | None = None,
) -> SimpleYamlFrontmatter:
return SimpleYamlFrontmatter(parse_yaml_mapping(content))
def format_frontmatter(frontmatter: SimpleYamlFrontmatter, body: str) -> str:
rendered = format_yaml_mapping(frontmatter.to_dict())
return f"---\n{rendered}\n---\n\n{body.lstrip()}"
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)
@@ -0,0 +1,238 @@
"""Convert supported Claude Code hooks into Codex hook config."""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from migrate.common import (
ConversionResult,
GeneratedText,
MigrationReportItem,
PlannedArtifact,
ScopePaths,
json_object,
json_string,
path_exists_with_exact_case,
read_json_mapping_file,
)
from migrate.settings import CLAUDE_SETTINGS_JSON_RELATIVE
CODEX_HOOKS_PATH = Path(".codex") / "hooks.json"
CODEX_HOOK_EVENTS = (
"PreToolUse",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",
"Stop",
)
CODEX_HOOK_MATCHER_EVENTS = frozenset(("PreToolUse", "PostToolUse", "SessionStart"))
@dataclass(frozen=True)
class ClaudeHookCommand:
command: str
timeout_sec: int | None = None
status_message: str | None = None
@classmethod
def from_mapping(cls, hook_config: Mapping[str, object]) -> ClaudeHookCommand | None:
command = json_string(hook_config.get("command"))
if command is None or not command.strip():
return None
timeout_value = hook_config.get("timeout")
if timeout_value is None:
timeout_value = hook_config.get("timeoutSec")
return cls(
command=command,
timeout_sec=json_int(timeout_value),
status_message=json_string(hook_config.get("statusMessage")),
)
def to_mapping(self) -> dict[str, object]:
result: dict[str, object] = {
"type": "command",
"command": self.command,
}
if self.timeout_sec is not None:
result["timeout"] = self.timeout_sec
if self.status_message is not None:
result["statusMessage"] = self.status_message
return result
@dataclass(frozen=True)
class ClaudeHookMatcherGroup:
event_name: str
matcher: str | None
hooks: tuple[ClaudeHookCommand, ...]
def to_mapping(self) -> dict[str, object]:
result: dict[str, object] = {
"hooks": [hook.to_mapping() for hook in self.hooks],
}
if self.matcher is not None:
result["matcher"] = self.matcher
return result
@dataclass(frozen=True)
class ClaudeHooks:
matcher_groups: tuple[ClaudeHookMatcherGroup, ...] = ()
source_paths: tuple[Path, ...] = ()
unsupported_fields: tuple[str, ...] = ()
@classmethod
def from_scope(cls, scope_root: Path) -> ClaudeHooks:
hook_sets = [
cls.from_settings_mapping(relative_path, outcome.data)
for relative_path in CLAUDE_SETTINGS_JSON_RELATIVE
if path_exists_with_exact_case(scope_root / relative_path)
for outcome in (read_json_mapping_file(scope_root / relative_path),)
if outcome.exists and outcome.ok
]
return cls(
matcher_groups=tuple(
matcher_group
for hook_set in hook_sets
for matcher_group in hook_set.matcher_groups
),
source_paths=tuple(
source_path
for hook_set in hook_sets
for source_path in hook_set.source_paths
),
unsupported_fields=tuple(
unsupported_field
for hook_set in hook_sets
for unsupported_field in hook_set.unsupported_fields
),
)
@classmethod
def from_settings_mapping(
cls,
relative_path: Path,
settings: Mapping[str, object],
) -> ClaudeHooks:
hooks_config = json_object(settings.get("hooks"))
if not hooks_config:
return cls()
matcher_groups: list[ClaudeHookMatcherGroup] = []
unsupported_fields: list[str] = []
for event_name, groups_value in hooks_config.items():
if event_name not in CODEX_HOOK_EVENTS:
unsupported_fields.append(f"hooks.{event_name}")
continue
for group_config in json_object_tuple(groups_value):
matcher = json_string(group_config.get("matcher"))
if matcher is not None and event_name not in CODEX_HOOK_MATCHER_EVENTS:
unsupported_fields.append(f"hooks.{event_name}.matcher")
matcher = None
if "if" in group_config:
unsupported_fields.append(f"hooks.{event_name}.if")
hook_commands: list[ClaudeHookCommand] = []
for hook_config in json_object_tuple(group_config.get("hooks")):
hook_type = json_string(hook_config.get("type")) or "command"
if hook_type != "command":
unsupported_fields.append(
f"hooks.{event_name}.hooks[].type:{hook_type}"
)
continue
if bool(hook_config.get("async")):
unsupported_fields.append(f"hooks.{event_name}.hooks[].async")
continue
hook_command = ClaudeHookCommand.from_mapping(hook_config)
if hook_command is None:
unsupported_fields.append(f"hooks.{event_name}.hooks[].command")
continue
hook_commands.append(hook_command)
if hook_commands:
matcher_groups.append(
ClaudeHookMatcherGroup(
event_name=event_name,
matcher=matcher,
hooks=tuple(hook_commands),
)
)
return cls(
matcher_groups=tuple(matcher_groups),
source_paths=(relative_path,),
unsupported_fields=tuple(sorted(set(unsupported_fields))),
)
def render_codex_file(self) -> str:
hooks_payload: dict[str, list[dict[str, object]]] = {}
for matcher_group in self.matcher_groups:
hooks_payload.setdefault(matcher_group.event_name, []).append(
matcher_group.to_mapping()
)
return json.dumps({"hooks": hooks_payload}, indent=2) + "\n"
def report_detail(self) -> str:
runtime_caveats = (
"Rewritten for Codex hooks; review behavior before relying on it. "
"Codex hooks require `[features].codex_hooks = true`, only execute "
"`command` handlers, skip `async` / `prompt` / `agent` handlers, ignore "
"`matcher` for `UserPromptSubmit` and `Stop`, and `PreToolUse` / "
"`PostToolUse` currently run for shell commands only."
)
if not self.unsupported_fields:
return runtime_caveats
return (
"Unsupported Claude hook fields need review: "
+ ", ".join(f"`{field_name}`" for field_name in self.unsupported_fields)
+ f". {runtime_caveats}"
)
def has_convertible_hooks(scope_root: Path) -> bool:
return bool(ClaudeHooks.from_scope(scope_root).matcher_groups)
def report_hooks(scope: ScopePaths) -> ConversionResult:
claude_hooks = ClaudeHooks.from_scope(scope.source)
if not claude_hooks.matcher_groups:
return ConversionResult()
return ConversionResult(
artifacts=[
PlannedArtifact(
relative_path=CODEX_HOOKS_PATH,
payload=GeneratedText(claude_hooks.render_codex_file()),
)
],
report_items=[
MigrationReportItem(
"rewritten",
CODEX_HOOKS_PATH,
claude_hooks.report_detail(),
)
],
)
def json_int(value: object) -> int | None:
if value is None or isinstance(value, bool):
return None
try:
return int(str(value))
except ValueError:
return None
def json_object_tuple(value: object) -> tuple[Mapping[str, object], ...]:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return tuple(json_object(item) for item in value)
return ()
@@ -0,0 +1,81 @@
"""Discover and classify source instruction files for AGENTS.md migration.
Chooses the first supported instruction file for a project/global scope. Neutral
instruction files are safe for `AGENTS.md` symlinks; content with obvious
Claude-only lifecycle, hook, subagent, or permission assumptions is treated as
requiring a generated Codex-specific copy and manual rewrite.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from migrate.common import MigrationReportItem
INSTRUCTION_SOURCE_CANDIDATES = (
Path(".claude") / "CLAUDE.md",
Path("CLAUDE.md"),
Path("claude.md"),
Path("AGENTS.md"),
)
CLAUDE_ONLY_INSTRUCTION_MARKERS = (
"/hooks",
".claude/agents/",
".claude/settings",
"Subagent",
"subagent",
"permissionMode",
"ExitPlanMode",
)
MAX_AGENTS_MD_BYTES = 32 * 1024
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)
def validate_agents_md_files(target_root: Path) -> list[MigrationReportItem]:
report_items: list[MigrationReportItem] = []
for agents_file in sorted(target_root.rglob("AGENTS.md")):
relative_path = agents_file.relative_to(target_root)
size_bytes = agents_file.stat().st_size
if size_bytes > MAX_AGENTS_MD_BYTES:
report_items.append(
MigrationReportItem(
"warning",
relative_path,
f"{size_bytes / 1024:.1f}KB exceeds the 32KB review threshold.",
)
)
continue
report_items.append(
MigrationReportItem(
"ok",
relative_path,
f"{size_bytes / 1024:.1f}KB is under the 32KB review threshold.",
)
)
return report_items
@@ -0,0 +1,222 @@
"""Convert Claude Code MCP/settings JSON into Codex config TOML.
Reads Claude settings plus `.mcp.json` / `.claude.json`, maps model and sandbox
settings when there is a known Codex equivalent, and renders MCP server entries
for `.codex/config.toml`. Header/env forms are partially normalized to Codex
`bearer_token_env_var`, `env_http_headers`, `http_headers`, `env_vars`, and
literal `env` tables.
"""
from __future__ import annotations
import json
import re
import shutil
from collections.abc import Mapping
from pathlib import Path
from migrate.common import (
CODEX_CONFIG_PATH,
MigrationReportItem,
json_object,
json_string,
json_string_tuple,
path_exists_with_exact_case,
)
from migrate.settings import CLAUDE_MCP_JSON_RELATIVE
from utils.util import TomlValue
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"
)
def mcp_server_toml_table(
server_name: str,
server_config: Mapping[str, object],
enabled_servers: tuple[str, ...],
disabled_servers: frozenset[str],
) -> dict[str, TomlValue]:
table: dict[str, TomlValue] = {}
enabled = mcp_enabled_state(server_config)
if enabled is False:
table["enabled"] = False
elif enabled_servers and server_name not in enabled_servers:
table["enabled"] = False
elif server_name in disabled_servers:
table["enabled"] = False
if url := json_string(server_config.get("url")):
table["url"] = url
if command := json_string(server_config.get("command")):
table["command"] = command
if args := json_string_tuple(server_config.get("args")):
table["args"] = list(args)
if "headers" in server_config:
append_header_config(table, json_object(server_config["headers"]))
if "env" in server_config:
append_env_config(table, json_object(server_config["env"]))
return table
def mcp_report_items(
mcp_servers: tuple[tuple[str, dict[str, object]], ...],
) -> list[MigrationReportItem]:
report_items = [
MigrationReportItem(
"rewritten",
CODEX_CONFIG_PATH,
f"Converted {len(mcp_servers)} MCP server entries.",
)
]
for server_name, server_config in mcp_servers:
notes = mcp_manual_notes(server_name, server_config)
if notes:
report_items.append(
MigrationReportItem(
"manual_fix_required",
CODEX_CONFIG_PATH,
f"MCP server `{server_name}` needs review: {' '.join(notes)}",
)
)
return report_items
def append_header_config(
table: dict[str, TomlValue],
headers: Mapping[str, object],
) -> None:
static_headers: dict[str, str] = {}
env_headers: dict[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:
table["bearer_token_env_var"] = bearer_match.group(1)
continue
env_match = ENV_VAR_RE.match(header_value)
if env_match:
env_headers[key] = env_match.group(1)
continue
static_headers[key] = header_value
if static_headers:
table["http_headers"] = static_headers
if env_headers:
table["env_http_headers"] = env_headers
def append_env_config(
table: dict[str, TomlValue],
env: Mapping[str, object],
) -> None:
static_env: dict[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[key] = env_value
if env_vars:
table["env_vars"] = env_vars
if static_env:
table["env"] = static_env
def mcp_manual_notes(
server_name: str,
server_config: Mapping[str, object],
) -> tuple[str, ...]:
notes: list[str] = []
source_type = json_string(server_config.get("type"))
if source_type and source_type not in {"http", "stdio"}:
notes.append(
f"Claude MCP `type: {source_type}` was not written to Codex config; verify that the generated `url` or `command` is a supported Codex transport."
)
unsupported_fields = unsupported_mcp_server_fields(server_config)
if unsupported_fields:
notes.append(
"Review unsupported Claude MCP fields manually: "
+ ", ".join(f"`{field_name}`" for field_name in unsupported_fields)
+ "."
)
return tuple(notes)
def mcp_enabled_state(server_config: Mapping[str, object]) -> bool | None:
if server_config.get("enabled") is False:
return False
if server_config.get("disabled") is True:
return False
return None
def unsupported_mcp_server_fields(
server_config: Mapping[str, object],
) -> tuple[str, ...]:
supported = {
"args",
"command",
"disabled",
"enabled",
"env",
"headers",
"name",
"scope",
"type",
"url",
}
return tuple(sorted(key for key in server_config if key not in supported))
def read_claude_mcp_servers(source_root: Path) -> tuple[tuple[str, dict[str, object]], ...]:
servers: list[tuple[str, dict[str, object]]] = []
for relative_path in CLAUDE_MCP_JSON_RELATIVE:
source_file = source_root / relative_path
if not path_exists_with_exact_case(source_file):
continue
mcp_config = json_object(json.loads(source_file.read_text()))
for server_name, server_config in json_object(mcp_config.get("mcpServers")).items():
servers.append((server_name, json_object(server_config)))
return tuple(servers)
def validate_mcp_commands(config: dict[str, object]) -> list[MigrationReportItem]:
mcp_servers = config.get("mcp_servers")
if not isinstance(mcp_servers, dict):
return []
report_items: list[MigrationReportItem] = []
for server_name, server_config in sorted(mcp_servers.items()):
if not isinstance(server_config, dict):
continue
command = server_config.get("command")
if not command:
continue
command_text = str(command)
if shutil.which(command_text):
report_items.append(
MigrationReportItem(
"ok",
CODEX_CONFIG_PATH,
f"MCP server `{server_name}` command `{command_text}` is on PATH.",
)
)
else:
report_items.append(
MigrationReportItem(
"warning",
CODEX_CONFIG_PATH,
f"MCP server `{server_name}` command `{command_text}` was not found on PATH.",
)
)
return report_items
@@ -0,0 +1,16 @@
"""Report Claude Code plugin surfaces that need manual Codex migration.
Claude Code plugins and plugin marketplaces can bundle commands, agents, MCP
servers, skills, and hooks with provider-specific metadata. The migrator reports
their presence as manual follow-up; it does not install Codex plugins, copy
plugin trees, or read marketplace `source` entries.
"""
from __future__ import annotations
from migrate.common import ConversionResult, ScopePaths, report_manual_paths
from migrate.settings import CLAUDE_PLUGIN_MANUAL_PATHS
def report_plugins(scope: ScopePaths) -> ConversionResult:
return report_manual_paths(scope, CLAUDE_PLUGIN_MANUAL_PATHS)
@@ -0,0 +1,41 @@
"""Shared source path constants for migration discovery/reporting.
Constants here describe where Claude Code commonly stores instructions,
commands, skills, agents, MCP config, plugin references, and hooks. Paths are
relative to `ScopePaths.source`, the directory containing `.claude`, `.mcp.json`,
and similar source roots.
"""
from __future__ import annotations
from pathlib import Path
CLAUDE_SETTINGS_JSON_RELATIVE = (
Path(".claude") / "settings.json",
Path(".claude") / "settings.local.json",
)
CLAUDE_MCP_JSON_RELATIVE = (
Path(".mcp.json"),
Path(".claude.json"),
)
CLAUDE_PLUGIN_MANUAL_PATHS = (
(Path(".claude") / "plugins", "Claude Code plugins"),
(
Path(".claude") / "plugin-marketplaces.json",
"Claude Code plugin marketplace registry",
),
(
Path(".claude-plugin") / "marketplace.json",
"Claude Code plugin marketplace",
),
)
SOURCE_SCAN_ROOTS = (
(Path(".claude"), "primary source"),
)
SOURCE_SCOPE_MARKERS = (
Path(".claude"),
)
@@ -0,0 +1,393 @@
"""Convert Claude Code skills and commands into Codex skills.
Reads `.claude/skills/<name>/SKILL.md` and `.claude/skills/<name>.md`, then emits
`.agents/skills/<name>/SKILL.md` plus supported helper directories for directory
skills. Also wraps `.claude/commands/*.md` as
one-file Codex skills. Runtime placeholders, file expansion, shell
interpolation, and unsupported metadata are preserved with manual-review
caveats.
"""
from __future__ import annotations
import re
from collections.abc import Sequence
from pathlib import Path
from migrate.common import (
CODEX_SKILLS_ROOT,
ArtifactKind,
ConversionResult,
GeneratedText,
MigrationReportItem,
ParsedDocument,
PlannedArtifact,
SimpleYamlFrontmatter,
append_report_item,
format_bullets,
format_frontmatter,
format_manual_migration_block,
is_path_within_root,
manual_report_item,
parse_frontmatter,
unsupported_frontmatter_fields,
)
from utils.util import slugify_name
COMMAND_FILE_SOURCES = (
(Path(".claude") / "commands", "source-command", "source command"),
)
SKILL_SOURCE_ROOTS = (
Path(".claude") / "skills",
)
SKILL_SUPPORT_DIRS = ("scripts", "references", "assets")
def iter_skill_files(source_root: Path) -> tuple[Path, ...]:
if not source_root.exists():
return ()
single_file_skills = tuple(
source_file
for source_file in sorted(source_root.glob("*.md"))
if source_file.stem != "README"
)
directory_skills = tuple(sorted(source_root.glob("*/SKILL.md")))
return single_file_skills + directory_skills
def skill_target_name(source_file: Path) -> str:
if source_file.name == "SKILL.md":
return source_file.parent.name
return source_file.stem
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)
def convert_skills(source_root: Path) -> ConversionResult:
result = convert_skill_files(source_root / ".claude" / "skills")
result.add(convert_command_skills(source_root))
return result
def convert_skill_files(source_root: Path) -> ConversionResult:
result = ConversionResult()
for source_file in iter_skill_files(source_root):
artifacts, report_item = convert_skill_file(source_file)
result.artifacts.extend(artifacts)
result.summary.skills += 1
result.report_items.append(report_item)
return result
def convert_command_skills(source_root: Path) -> ConversionResult:
result = ConversionResult()
for command_source_root, name_prefix, provider in COMMAND_FILE_SOURCES:
result.add(
convert_markdown_command_files(
source_root / command_source_root,
name_prefix,
provider,
)
)
return result
def codex_skill_frontmatter(name: str, description: str) -> SimpleYamlFrontmatter:
return SimpleYamlFrontmatter(
{
"name": name,
"description": description,
}
)
def convert_skill_file(source_file: Path) -> tuple[list[PlannedArtifact], MigrationReportItem]:
document = ParsedDocument.from_file(source_file)
name = document.frontmatter.required_string("name")
description = document.frontmatter.required_string("description")
allowed_tools = document.frontmatter.string_tuple("allowed-tools")
unsupported_fields = unsupported_frontmatter_fields(
document.frontmatter.to_dict(),
("name", "description", "allowed-tools"),
)
artifacts = [
PlannedArtifact(
relative_path=CODEX_SKILLS_ROOT / skill_target_name(source_file) / "SKILL.md",
payload=GeneratedText(
render_skill(
document.body,
name=name,
description=description,
allowed_tools=allowed_tools,
unsupported_fields=unsupported_fields,
)
),
kind=ArtifactKind.SKILL,
)
]
artifacts.extend(skill_support_artifacts(source_file))
return artifacts, skill_report_item(source_file, allowed_tools, unsupported_fields)
def skill_support_artifacts(source_file: Path) -> list[PlannedArtifact]:
if source_file.name != "SKILL.md":
return []
artifacts: list[PlannedArtifact] = []
skill_root = source_file.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 support_file in sorted(
source_files,
key=lambda path: path.relative_to(skill_root).as_posix(),
):
artifacts.append(
PlannedArtifact.from_source_file(
support_file,
target_root / support_file.relative_to(skill_root),
)
)
return artifacts
def render_skill(
body: str,
*,
name: str,
description: str,
allowed_tools: tuple[str, ...],
unsupported_fields: tuple[str, ...],
) -> str:
return format_frontmatter(
codex_skill_frontmatter(name, description),
render_skill_body(body, allowed_tools, unsupported_fields),
)
def render_skill_body(
body: str,
allowed_tools: tuple[str, ...],
unsupported_fields: tuple[str, ...],
) -> str:
manual_notes: list[str] = []
if 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(allowed_tools)}"
)
if unsupported_fields:
manual_notes.append(
"Review unsupported Claude skill fields manually: "
f"{', '.join(f'`{field_name}`' for field_name in unsupported_fields)}."
)
if not manual_notes:
return body
return f"{body.rstrip()}\n\n{format_manual_migration_block(manual_notes)}\n"
def skill_report_detail(
allowed_tools: tuple[str, ...],
unsupported_fields: tuple[str, ...],
) -> str:
caveats: list[str] = []
if allowed_tools:
caveats.append("allowed-tools")
caveats.extend(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)
+ "."
)
def skill_report_item(
source_file: Path,
allowed_tools: tuple[str, ...],
unsupported_fields: tuple[str, ...],
) -> MigrationReportItem:
report_items: list[MigrationReportItem] = []
detail = skill_report_detail(allowed_tools, unsupported_fields)
append_report_item(
report_items,
allowed_tools or unsupported_fields,
CODEX_SKILLS_ROOT / skill_target_name(source_file) / "SKILL.md",
detail,
detail,
)
return report_items[0]
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")):
artifact, report_item = convert_command_file(
source_root,
source_file,
name_prefix,
provider,
)
result.artifacts.append(artifact)
result.summary.skills += 1
result.report_items.append(report_item)
return result
def convert_command_file(
source_root: Path,
source_file: Path,
name_prefix: str,
provider: str,
) -> tuple[PlannedArtifact, MigrationReportItem]:
document = ParsedDocument.from_file(source_file)
source_name = "-".join(source_file.relative_to(source_root).with_suffix("").parts)
name = slugify_name(f"{name_prefix}-{source_name}")
description = document.frontmatter.optional_string("description")
if not description:
description = f"Run the migrated {provider} `{source_name}`."
unsupported_fields = unsupported_frontmatter_fields(
document.frontmatter.to_dict(),
("description",),
)
caveats = command_caveats(document.body, unsupported_fields)
artifact = PlannedArtifact(
relative_path=CODEX_SKILLS_ROOT / name / "SKILL.md",
payload=GeneratedText(
render_command_skill(
document.body,
name=name,
description=description,
provider=provider,
source_name=source_name,
caveats=caveats,
)
),
kind=ArtifactKind.SKILL,
)
return artifact, command_report_item(name, provider, source_name)
def render_command_skill(
body: str,
*,
name: str,
description: str,
provider: str,
source_name: str,
caveats: tuple[str, ...],
) -> str:
manual_notes = [
f"Migrated from {provider} `{source_name}` into a Codex skill. "
f"Invoke it as `${name}` and manually rewrite any slash-command behavior that depended on provider-specific runtime expansion."
]
manual_notes.extend(caveats)
template_body = body.strip() or "No command template body was found."
return format_frontmatter(
codex_skill_frontmatter(name, description),
f"# {name}\n\n"
"Use this skill when the user asks to run the migrated "
f"{provider} `{source_name}`.\n\n"
"## Command Template\n\n"
f"{template_body}\n\n"
f"{format_manual_migration_block(manual_notes)}\n",
)
def validate_skill_files(target_root: Path) -> list[MigrationReportItem]:
skills_root = target_root / CODEX_SKILLS_ROOT
if not skills_root.exists():
return []
report_items: list[MigrationReportItem] = []
for skill_file in sorted(skills_root.glob("*/SKILL.md")):
relative_path = skill_file.relative_to(target_root)
document = parse_frontmatter(skill_file.read_text(), skill_file)
missing = [
key
for key in ("name", "description")
if not document.frontmatter.optional_string(key)
]
if missing:
report_items.append(
MigrationReportItem(
"error",
relative_path,
"skill frontmatter missing " + ", ".join(missing) + ".",
)
)
continue
report_items.append(
MigrationReportItem(
"ok",
relative_path,
"skill frontmatter has name and description.",
)
)
return report_items
def command_report_detail(provider: str, source_name: str) -> str:
return (
f"Converted {provider} `{source_name}` to a single-file Codex skill; "
"review invocation and template placeholder semantics."
)
def command_report_item(
name: str,
provider: str,
source_name: str,
) -> MigrationReportItem:
return manual_report_item(
CODEX_SKILLS_ROOT / name / "SKILL.md",
command_report_detail(provider, source_name),
)
@@ -0,0 +1 @@
# Migration script helper modules.
@@ -0,0 +1,128 @@
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)
@@ -0,0 +1,306 @@
from __future__ import annotations
import glob
import json
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TypeAlias
YamlScalar: TypeAlias = str | bool | int | float | None
YamlValue: TypeAlias = YamlScalar | Sequence[YamlScalar]
TomlScalar: TypeAlias = str | bool | int | float | None
TomlValue: TypeAlias = object
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)
@dataclass(frozen=True)
class TomlMultilineString:
value: str
def parse_yaml_mapping(content: str) -> dict[str, YamlValue]:
"""Parse the small YAML-frontmatter subset used by Claude metadata."""
result: dict[str, YamlValue] = {}
current_key: str | None = None
for raw_line in content.splitlines():
if not raw_line.strip():
continue
if raw_line.startswith(" - ") and current_key:
current_value = result.setdefault(current_key, [])
if not isinstance(current_value, list):
current_value = [current_value]
result[current_key] = current_value
current_value.append(parse_yaml_value(raw_line[4:].strip()))
continue
key, separator, value = raw_line.partition(":")
if not separator:
continue
current_key = key.strip()
value = value.strip()
result[current_key] = parse_yaml_value(value) if value else []
return result
def parse_yaml_value(value: str) -> YamlValue:
if value in {"true", "True"}:
return True
if value in {"false", "False"}:
return False
if value in {"null", "Null", "~"}:
return None
if value.startswith("[") and value.endswith("]"):
return tuple(
parse_yaml_value(item)
for item in split_delimited_values(value[1:-1])
if item
)
if value.startswith('"') and value.endswith('"'):
try:
return json.loads(value)
except json.JSONDecodeError:
return value[1:-1]
if value.startswith("'") and value.endswith("'"):
return value[1:-1].replace("''", "'")
return value
def split_delimited_values(content: str) -> tuple[str, ...]:
values: list[str] = []
token: list[str] = []
quote: str | None = None
escaped = False
for char in content:
if escaped:
token.append(char)
escaped = False
continue
if char == "\\" and quote == '"':
token.append(char)
escaped = True
continue
if quote:
token.append(char)
if char == quote:
quote = None
continue
if char in {"'", '"'}:
token.append(char)
quote = char
continue
if char == ",":
values.append("".join(token).strip())
token = []
continue
token.append(char)
values.append("".join(token).strip())
return tuple(values)
def format_yaml_mapping(values: Mapping[str, YamlValue]) -> str:
return "\n".join(
f"{key}: {format_yaml_value(value)}" for key, value in values.items()
)
def format_yaml_value(value: YamlValue) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return "[" + ", ".join(format_yaml_value(item) for item in value) + "]"
return json.dumps(str(value))
def render_toml_document(values: Mapping[str, TomlValue]) -> str:
lines: list[str] = []
append_toml_entries(lines, values)
for key, value in values.items():
if isinstance(value, Mapping):
append_toml_table(lines, (key,), value)
return "\n".join(lines).rstrip() + "\n"
def append_toml_table(
lines: list[str],
path: tuple[str, ...],
values: Mapping[str, TomlValue],
) -> None:
append_blank_line(lines)
lines.append("[" + ".".join(format_toml_key(path_part) for path_part in path) + "]")
append_toml_entries(lines, values)
for key, value in values.items():
if isinstance(value, Mapping):
append_toml_table(lines, (*path, key), value)
def append_toml_entries(lines: list[str], values: Mapping[str, TomlValue]) -> None:
for key, value in values.items():
if isinstance(value, Mapping):
continue
lines.append(f"{format_toml_key(key)} = {format_toml_value(value)}")
def append_blank_line(lines: list[str]) -> None:
if lines and lines[-1]:
lines.append("")
def format_toml_key(key: str) -> str:
if re.fullmatch(r"[A-Za-z0-9_-]+", key):
return key
return json.dumps(key)
def format_toml_value(value: TomlValue) -> str:
if isinstance(value, TomlMultilineString):
return format_toml_multiline_string(value.value)
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return '""'
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return "[" + ", ".join(format_toml_value(item) for item in value) + "]"
return json.dumps(str(value))
def format_toml_multiline_string(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
return f'"""{escaped}"""'
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)