ChatGPT Codex CLI OpenAI Skill

migrate-to-codex

Migrate supported instruction files, skills, agents, and MCP config into Codex project and global files.

LLM Mart · 0 points · 26 views 1 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download openai-skills-skills_.curated_migrate-to-codex-49f948f.zip · 39 KB
Part of openai/skills — 44 skills

Install

skills CLI npx skills add https://github.com/openai/skills/tree/main/skills/.curated/migrate-to-codex
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install openai-skills@llmmart
Git git clone https://github.com/openai/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole openai/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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. Before finishing, update the TODO list so every finished step is marked completed and no step remains in_progress. 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.

MIGRATE_TO_CODEX='python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py'

Inspect the migration before writing.

$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.

$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.

$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.

Files (skills)
  • agents
    • openai.yaml 144 B
      interface:
        display_name: "Migrate to Codex"
        short_description: "Migrate supported instruction files, skills, agents, and config into Codex"
      
  • references
    • differences.md 13.4 KB
      # 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
      
  • scripts
    • migrate
      • agents.py 9.8 KB
        """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
        
      • codex_config.py 3.9 KB
        """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
        
      • common.py 11.2 KB
        """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)
        
      • hooks.py 8 KB
        """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 ()
        
      • instructions.py 2.4 KB
        """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
        
      • mcps.py 6.9 KB
        """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
        
      • plugins.py 660 B
        """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)
        
      • settings.py 1 KB
        """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"),
        )
        
      • skills.py 12.3 KB
        """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),
            )
        
      • __init__.py 285 B
        """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.
        """
        
    • utils
      • scan.py 4.3 KB
        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)
        
      • util.py 9.2 KB
        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)
        
      • __init__.py 35 B
        # Migration script helper modules.
        
    • cli.py 29.4 KB
      """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()
      
    • migrate-to-codex.py 208 B
      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()
      
  • LICENSE.txt 11.1 KB
    
                                     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.
  • SKILL.md 7.8 KB
    ---
    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. Before finishing, update the TODO list so every finished step is marked `completed` and no step remains `in_progress`. 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`.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related