migrate-to-codex
Migrate supported instruction files, skills, agents, and MCP config into Codex project and global files.
Install
npx skills add https://github.com/openai/skills/tree/main/skills/.curated/migrate-to-codex
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install openai-skills@llmmart
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:
Start by using Codex's built-in TODO/task list tool. Do not create
MIGRATION_TODOS.mdor any TODO file unless the user explicitly asks. The TODO list input has aplanarray whose items each havestepandstatus; use statusespending,in_progress, andcompleted. Make the TODOs specific to the selected artifacts. Before finishing, update the TODO list so every finished step is markedcompletedand no step remainsin_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.tomlMCP servers - Inspect
.claude/settings.jsonhooks →.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
- Inspect
Read
references/differences.md(and refresh Codex docs if itsDocs last checkeddate is old).Scan and inspect before writing:
--scan-onlylists active and inactive source surfaces.--planprints staged Codex artifact paths and report rows.--doctorsummarizes readiness, manual-review work, and validation risks.
Convert surfaces in the same order the CLI uses:
- instructions:
CLAUDE.md/AGENTS.mdtoAGENTS.md - plugins: report Claude plugin trees and marketplaces as manual migration work
- hooks: rewrite supported Claude hooks into
.codex/hooks.jsonand enable[features].codex_hooks = true - skills and commands: write Codex skills under
.agents/skills/ - config: write
.codex/config.tomlfrom Claude model/sandbox settings and MCP servers, includingpersonality = "friendly"when config is generated - subagents: write Codex custom agents under
.codex/agents/
- instructions:
Dry-run, then write the selected target. Use
--replaceonly when orphan generated skills or agents should be deleted.Inspect the terminal output and
.codex/migrate-to-codex-report.txtafter real runs.Review generated artifacts in this order:
AGENTS.md,.agents/skills/,.codex/config.toml,.codex/hooks.json,.codex/agents/, then report-only plugin items.Run
--validate-targetagainst each target after edits.Re-run checks and
--dry-runafter edits.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 useCurrent Projectas the heading. Do not add prose before or after the table output.Use exactly these columns:
northstar-support-portal
Status Item Notes AddedSlash commandpr-reviewConverted into a Codex skill AddedSubagentrelease-leadAdded as a Codex subagent Check before usingHookPreToolUseConverted, but some Claude hook behavior differs in Codex Not AddedHookNotificationCodex does not have an equivalent notification hook Not AddedPluginteam-macrosPlugin needs manual setup Statusmust beAdded,Check before using, orNot Added. UseAddedwhen a Codex-facing artifact was created or changed and needs no special review. UseCheck before usingwhen a Codex-facing artifact was created or changed but the migration changed semantics, inferred behavior, preserved tool rules as guidance, or dropped unsupported behavior. UseNot Addedwhen a source artifact was detected but no Codex-facing artifact was created.Itemcombines the artifact type and concrete item name in one cell. Artifact type must be singular:Skill,Slash command,Subagent,MCP,Hook, orPlugin. Wrap the artifact type in inline code; write the item name as plain text after it.Notesis always required; never leave it empty. Keep notes short, plain, and literal. Avoid internal implementation terms such as runtime expansion. Prefer phrases likeConverted 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, orPlugin marketplace needs manual setup.
Self-Healing Loop
Keep looping until the selected migration is complete:
- Run
--planor--doctor. - Run the migration with
--dry-run. - Run the migration for real.
- Fix every generated
## MANUAL MIGRATION REQUIREDblock and everymanual_fix_requiredorskippedreport row that can be resolved inside Codex artifacts. - Run
--validate-target. - 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.
Reviews (0)
No reviews yet.
No comments yet.