diff --git a/skills/.curated/migrate-to-codex/LICENSE.txt b/skills/.curated/migrate-to-codex/LICENSE.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/skills/.curated/migrate-to-codex/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/skills/.curated/migrate-to-codex/SKILL.md b/skills/.curated/migrate-to-codex/SKILL.md new file mode 100644 index 0000000..f892d99 --- /dev/null +++ b/skills/.curated/migrate-to-codex/SKILL.md @@ -0,0 +1,114 @@ +--- +name: migrate-to-codex +description: Migrate supported instruction files, skills, agents, and MCP config into Codex project and global files. +--- + +# Migrate to Codex + +## Autonomy + +Keep going until the selected migration is completely done: run the migrator, inspect the report, fix migrated Codex instructions/skills/agents/MCP config, and re-run checks without stopping to ask for confirmation of the next step. If the user has selected a target, do not ask before creating, editing, replacing, or deleting generated Codex artifacts in that target (`AGENTS.md`, `.codex/`, `.agents/`, or `~/.codex/`). Preserve unrelated existing Codex config entries in `.codex/config.toml` or `~/.codex/config.toml`, such as `notify`, `projects`, `marketplaces`, or unrelated MCP servers; do not ask about them unless they fail validation or directly conflict with the migration. Do not edit source Claude Code files (`.claude/`, `~/.claude/`, `.mcp.json`, or `.claude.json`), unrelated project code, secrets, or another repository. + +## Migration Order + +Run the migration in this order for each selected global or project source: + +1. Start by using Codex's built-in TODO/task list tool. Do not create `MIGRATION_TODOS.md` or any TODO file unless the user explicitly asks. The TODO list input has a `plan` array whose items each have `step` and `status`; use statuses `pending`, `in_progress`, and `completed`. Make the TODOs specific to the selected artifacts. Use literal source → Codex target labels, for example: + - Inspect `.claude/commands` → Codex skills/prompts + - Inspect `.claude/agents` → `.codex/agents` + - Inspect `.mcp.json` → `.codex/config.toml` MCP servers + - Inspect `.claude/settings.json` hooks → `.codex/hooks.json` + - Migrate safe selected artifacts → Codex files + - Validate generated `.codex/config.toml` + - Validate generated `.codex/agents` + - Report migrated artifacts and manual-review items + +2. Read `references/differences.md` (and refresh Codex docs if its `Docs last checked` date is old). + +3. Scan and inspect before writing: + - `--scan-only` lists active and inactive source surfaces. + - `--plan` prints staged Codex artifact paths and report rows. + - `--doctor` summarizes readiness, manual-review work, and validation risks. + +4. Convert surfaces in the same order the CLI uses: + - instructions: `CLAUDE.md` / `AGENTS.md` to `AGENTS.md` + - plugins: report Claude plugin trees and marketplaces as manual migration work + - hooks: rewrite supported Claude hooks into `.codex/hooks.json` and enable `[features].codex_hooks = true` + - skills and commands: write Codex skills under `.agents/skills/` + - config: write `.codex/config.toml` from Claude model/sandbox settings and MCP servers, including `personality = "friendly"` when config is generated + - subagents: write Codex custom agents under `.codex/agents/` + +5. Dry-run, then write the selected target. Use `--replace` only when orphan generated skills or agents should be deleted. + +6. Inspect the terminal output and `.codex/migrate-to-codex-report.txt` after real runs. + +7. Review generated artifacts in this order: `AGENTS.md`, `.agents/skills/`, `.codex/config.toml`, `.codex/hooks.json`, `.codex/agents/`, then report-only plugin items. + +8. Run `--validate-target` against each target after edits. + +9. Re-run checks and `--dry-run` after edits. + +10. Return the final migration report as one markdown table per scope that has rows. The tables cover only the non-native follow-up migration work you performed, such as skills created from slash commands, subagents, MCP servers, hooks, unsupported/local plugin notes, and manual-review caveats. Include programmatic native import rows for config, instructions, skills, or supported plugins only if you personally migrated them in this follow-up run. + + If only one scope has rows, render only the table with no heading. If multiple scopes have rows, render one heading before each table. Use `**User Config**` for user-scope rows. For project-scope rows, use the actual project folder name as the heading, for example `**northstar-support-portal**`; do not use `Current Project` as the heading. Do not add prose before or after the table output. + + Use exactly these columns: + + **northstar-support-portal** + + | Status | Item | Notes | + | --- | --- | --- | + | `Added` | `Slash command` pr-review | Converted into a Codex skill | + | `Added` | `Subagent` release-lead | Added as a Codex subagent | + | `Check before using` | `Hook` PreToolUse | Converted, but some Claude hook behavior differs in Codex | + | `Not Added` | `Hook` Notification | Codex does not have an equivalent notification hook | + | `Not Added` | `Plugin` team-macros | Plugin needs manual setup | + + `Status` must be `Added`, `Check before using`, or `Not Added`. Use `Added` when a Codex-facing artifact was created or changed and needs no special review. Use `Check before using` when a Codex-facing artifact was created or changed but the migration changed semantics, inferred behavior, preserved tool rules as guidance, or dropped unsupported behavior. Use `Not Added` when a source artifact was detected but no Codex-facing artifact was created. `Item` combines the artifact type and concrete item name in one cell. Artifact type must be singular: `Skill`, `Slash command`, `Subagent`, `MCP`, `Hook`, or `Plugin`. Wrap the artifact type in inline code; write the item name as plain text after it. `Notes` is always required; never leave it empty. Keep notes short, plain, and literal. Avoid internal implementation terms such as runtime expansion. Prefer phrases like `Converted into a Codex skill`, `Added as a Codex subagent`, `Added to Codex config`, `Converted into a Codex hook`, `Converted, but some Claude hook behavior differs in Codex`, `Codex does not have an equivalent notification hook`, `Plugin needs manual setup`, or `Plugin marketplace needs manual setup`. + +## Self-Healing Loop + +Keep looping until the selected migration is complete: + +1. Run `--plan` or `--doctor`. +2. Run the migration with `--dry-run`. +3. Run the migration for real. +4. Fix every generated `## MANUAL MIGRATION REQUIRED` block and every `manual_fix_required` or `skipped` report row that can be resolved inside Codex artifacts. +5. Run `--validate-target`. +6. Re-run the migrator and validator until the report and validator have no actionable generated-artifact fixes left. + +Do not edit source Claude Code files, unrelated project code, secrets, or another repository during this loop. If a report row requires source-provider changes or product judgment, leave the generated Codex artifact with clear manual guidance instead of changing the source. + +## Commands + +Choose the migrator command. + + ```bash + MIGRATE_TO_CODEX='python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py' + ``` + +Inspect the migration before writing. + + ```bash + $MIGRATE_TO_CODEX --source ~/.claude/ --scan-only + $MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --plan + $MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --doctor + ``` + +Dry-run, then run without `--dry-run`, for global and project. + + ```bash + $MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --dry-run + $MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ + $MIGRATE_TO_CODEX --source ./.claude/ --target ./.codex/ --dry-run + $MIGRATE_TO_CODEX --source ./.claude/ --target ./.codex/ + ``` + +Run the post-migration validator against each target after edits. + + ```bash + $MIGRATE_TO_CODEX --validate-target ~/.codex/ + $MIGRATE_TO_CODEX --validate-target ./.codex/ + ``` + +Run `$MIGRATE_TO_CODEX --help` for flags (`--scan-only`, `--plan`, `--doctor`, `--validate-target`, defaults, and so on). Deep tables and more links are in `references/differences.md`. diff --git a/skills/.curated/migrate-to-codex/agents/openai.yaml b/skills/.curated/migrate-to-codex/agents/openai.yaml new file mode 100644 index 0000000..32110f9 --- /dev/null +++ b/skills/.curated/migrate-to-codex/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Migrate to Codex" + short_description: "Migrate supported instruction files, skills, agents, and config into Codex" diff --git a/skills/.curated/migrate-to-codex/references/differences.md b/skills/.curated/migrate-to-codex/references/differences.md new file mode 100644 index 0000000..68a12b5 --- /dev/null +++ b/skills/.curated/migrate-to-codex/references/differences.md @@ -0,0 +1,167 @@ +# Migration Differences + +## Summary + +This reference covers Claude Code to Codex migration only. It lists migration differences, partial mappings, and unsupported Claude Code behavior. Direct 1:1 mappings are intentionally omitted. When the converter preserves Claude-only semantics as prompt guidance, it also emits a `manual_fix_required` report row and writes a `## MANUAL MIGRATION REQUIRED` block into the generated file. + +Docs last checked: 2026-04-20. If today's date is later, re-open the official Codex docs below and the Claude Code docs map before trusting these mappings. + +## Instructions + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| `.claude/CLAUDE.md`, `CLAUDE.md`, or `claude.md` | `AGENTS.md` symlink | Linked automatically when content looks provider-neutral | This keeps one shared instruction body instead of duplicating docs. | +| Root `AGENTS.md` | Root `AGENTS.md` | Reported as active | The converter does not overwrite or symlink the target file to itself. | +| Instruction content with `/hooks`, `.claude/agents/`, settings paths, subagent language, or permission-mode assumptions | Generated `AGENTS.md` copy | Manual rewrite pass | The converter intentionally breaks the symlink when obvious Claude-only semantics need a Codex-specific edit. | + +## Commands + +| Source | Codex | Migration behavior | Caveat | +| --- | --- | --- | --- | +| `.claude/commands/*.md` | `.agents/skills/source-command-/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//SKILL.md` | `.agents/skills//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/.md` | `.agents/skills//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.]` | 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//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 diff --git a/skills/.curated/migrate-to-codex/scripts/cli.py b/skills/.curated/migrate-to-codex/scripts/cli.py new file mode 100644 index 0000000..9f01ea1 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/cli.py @@ -0,0 +1,907 @@ +"""CLI orchestration for migrate-to-codex. + +This module owns argument parsing, scan/dry-run/report rendering, deployment +planning, and file writes. Provider-specific conversions live in +`migrate.` 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() diff --git a/skills/.curated/migrate-to-codex/scripts/migrate-to-codex.py b/skills/.curated/migrate-to-codex/scripts/migrate-to-codex.py new file mode 100644 index 0000000..7a2def4 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate-to-codex.py @@ -0,0 +1,12 @@ +from pathlib import Path +import sys + + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from cli import * # noqa: F403,E402 +from cli import main # noqa: E402 + + +if __name__ == "__main__": + main() diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/__init__.py b/skills/.curated/migrate-to-codex/scripts/migrate/__init__.py new file mode 100644 index 0000000..16ac85a --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/__init__.py @@ -0,0 +1,6 @@ +"""Section-level migration code for migrate-to-codex. + +Each module owns one source-to-Codex surface: instructions, skills/commands, +subagents, MCP/config, hooks, plugin-like sources, or shared primitives. +`cli.py` should orchestrate these modules instead of embedding conversions. +""" diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/agents.py b/skills/.curated/migrate-to-codex/scripts/migrate/agents.py new file mode 100644 index 0000000..b83e590 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/agents.py @@ -0,0 +1,326 @@ +"""Convert Claude Code subagents into Codex custom-agent TOML. + +Reads `.claude/agents/*.md` files, parses their frontmatter/body, and emits +`.codex/agents/.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 diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/codex_config.py b/skills/.curated/migrate-to-codex/scripts/migrate/codex_config.py new file mode 100644 index 0000000..915f120 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/codex_config.py @@ -0,0 +1,134 @@ +"""Render Codex config from Claude Code settings and MCP inputs. + +This module owns `.codex/config.toml` generation. It reads Claude Code +settings for model/sandbox equivalents, asks `mcps.py` for MCP server tables, +and adds Codex-native defaults that are not MCP-specific, such as the friendly +personality used for Claude Code migrations. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +from migrate.common import ( + CODEX_CONFIG_PATH, + ConversionResult, + GeneratedText, + MigrationReportItem, + MigrationSummary, + PlannedArtifact, + ScopePaths, + json_string, + json_string_tuple, + load_scope_settings, + map_model_name, + map_permission_mode, +) +from migrate.hooks import has_convertible_hooks +from migrate.mcps import ( + mcp_report_items, + mcp_server_toml_table, + read_claude_mcp_servers, + validate_mcp_commands, +) +from utils.util import TomlValue, render_toml_document + + +DEFAULT_CODEX_PERSONALITY = "friendly" + + +def convert_settings(scope: ScopePaths) -> ConversionResult: + settings = load_scope_settings(scope.source) + mcp_servers = read_claude_mcp_servers(scope.source) + if not settings and not mcp_servers: + return ConversionResult() + + enabled_mcp_servers = json_string_tuple(settings.get("enabledMcpjsonServers")) + disabled_mcp_servers = frozenset( + json_string_tuple(settings.get("disabledMcpjsonServers")) + ) + config_toml = render_codex_config( + model=json_string(settings.get("model")), + permission_mode=json_string(settings.get("permissionMode")), + enabled_mcp_servers=enabled_mcp_servers, + disabled_mcp_servers=disabled_mcp_servers, + mcp_servers=mcp_servers, + codex_hooks_enabled=has_convertible_hooks(scope.source), + ) + if not config_toml.strip(): + return ConversionResult() + return ConversionResult( + summary=MigrationSummary(mcp_servers=len(mcp_servers)), + artifacts=[ + PlannedArtifact( + relative_path=CODEX_CONFIG_PATH, + payload=GeneratedText(config_toml), + ) + ], + report_items=mcp_report_items(mcp_servers), + ) + + +def render_codex_config( + model: str | None, + permission_mode: str | None, + enabled_mcp_servers: tuple[str, ...], + disabled_mcp_servers: frozenset[str], + mcp_servers: tuple[tuple[str, dict[str, object]], ...], + codex_hooks_enabled: bool, +) -> str: + document: dict[str, TomlValue] = {} + if model: + document["model"] = map_model_name(model) + sandbox_mode = map_permission_mode(permission_mode) + if sandbox_mode: + document["sandbox_mode"] = sandbox_mode + + if mcp_servers: + document["mcp_servers"] = { + server_name: mcp_server_toml_table( + server_name, + server_config, + enabled_mcp_servers, + disabled_mcp_servers, + ) + for server_name, server_config in mcp_servers + } + + if codex_hooks_enabled: + document["features"] = {"codex_hooks": True} + + if document: + document = {"personality": DEFAULT_CODEX_PERSONALITY, **document} + + return render_toml_document(document) + + +def validate_config_toml(target_root: Path) -> list[MigrationReportItem]: + config_path = target_root / CODEX_CONFIG_PATH + if not config_path.exists(): + return [ + MigrationReportItem( + "warning", + CODEX_CONFIG_PATH, + "not present; no Codex config to validate.", + ) + ] + + try: + parsed = tomllib.loads(config_path.read_text()) + except tomllib.TOMLDecodeError as exc: + return [ + MigrationReportItem( + "error", + CODEX_CONFIG_PATH, + f"invalid TOML: {exc}.", + ) + ] + + report_items = [ + MigrationReportItem("ok", CODEX_CONFIG_PATH, "valid TOML."), + ] + report_items.extend(validate_mcp_commands(parsed)) + return report_items diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/common.py b/skills/.curated/migrate-to-codex/scripts/migrate/common.py new file mode 100644 index 0000000..d5d83a8 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/common.py @@ -0,0 +1,401 @@ +"""Shared data models, frontmatter rendering, reporting, and path helpers. + +Defines the artifact/report model used by every migration section, the +YAML-frontmatter adapter used for skills/agents/commands, Claude-model and +permission-mode partial mappings, and generic filesystem/report helpers. This +module should not know about one migration surface's control flow. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, fields as dataclass_fields +from enum import Enum +from pathlib import Path +from typing import TypeAlias + +from migrate.settings import CLAUDE_SETTINGS_JSON_RELATIVE +from utils.util import ( + format_yaml_mapping, + parse_yaml_mapping, + read_json_mapping_file, +) + + +FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.S) +CODEX_CONFIG_PATH = Path(".codex") / "config.toml" +CODEX_AGENTS_ROOT = Path(".codex") / "agents" +CODEX_SKILLS_ROOT = Path(".agents") / "skills" +SUMMARY_LABELS = { + "mcp_servers": "mcp servers", +} +PERMISSION_MODE_MAPPINGS = { + "acceptEdits": "workspace-write", + "readOnly": "read-only", +} +YamlScalar: TypeAlias = str | bool | int | float | None +YamlValue: TypeAlias = YamlScalar | Sequence[YamlScalar] + + +@dataclass(frozen=True) +class ScopePaths: + source: Path + is_global: bool + + +@dataclass(frozen=True) +class ModelMapping: + source_prefix: str + target_model: str + effort_mapping: tuple[tuple[str, str], ...] + + def map_effort(self, effort: str) -> str: + for source_effort, target_effort in self.effort_mapping: + if effort == source_effort: + return target_effort + return effort + + +MODEL_PREFIX_MAPPINGS = ( + ModelMapping( + "claude-opus", + "gpt-5.4", + (("low", "low"), ("medium", "medium"), ("high", "high"), ("max", "xhigh")), + ), + ModelMapping( + "claude-sonnet", + "gpt-5.4-mini", + (("low", "medium"), ("medium", "high"), ("high", "xhigh"), ("max", "xhigh")), + ), + ModelMapping( + "claude-haiku", + "gpt-5.4-mini", + (("low", "low"), ("medium", "medium"), ("high", "high"), ("max", "xhigh")), + ), +) + + +class ArtifactKind(Enum): + FILE = "file" + SKILL = "skill" + AGENT = "agent" + + +@dataclass(frozen=True) +class GeneratedText: + content: str + + +@dataclass(frozen=True) +class SourceCopy: + source_path: Path + + +@dataclass(frozen=True) +class SourceSymlink: + source_path: Path + + +ArtifactPayload: TypeAlias = GeneratedText | SourceCopy | SourceSymlink + + +@dataclass(frozen=True) +class MigrationReportItem: + status: str + path: Path + detail: str + + +@dataclass(frozen=True) +class SimpleYamlFrontmatter: + values: dict[str, YamlValue] + + def required_string(self, key: str) -> str: + return str(self.values[key]) + + def optional_string(self, key: str) -> str | None: + value = self.values.get(key) + if value is None: + return None + return str(value) + + def string_tuple(self, key: str) -> tuple[str, ...]: + value = self.values.get(key) + if value is None: + return () + + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return tuple(str(item).strip() for item in value if str(item).strip()) + + return tuple( + split_item + for split_item in (part.strip() for part in str(value).split(",")) + if split_item + ) + + def to_dict(self) -> dict[str, YamlValue]: + return self.values + + +@dataclass(frozen=True) +class ParsedDocument: + frontmatter: SimpleYamlFrontmatter + body: str + path: Path | None = None + + @classmethod + def from_file(cls, source_file: Path) -> ParsedDocument: + return parse_frontmatter(source_file.read_text(), source_file) + + +@dataclass(frozen=True) +class PlannedArtifact: + relative_path: Path + payload: ArtifactPayload + kind: ArtifactKind = ArtifactKind.FILE + + @classmethod + def for_skill(cls, source_file: Path, content: str) -> PlannedArtifact: + return cls( + relative_path=CODEX_SKILLS_ROOT / source_file.parent.name / "SKILL.md", + payload=GeneratedText(content), + kind=ArtifactKind.SKILL, + ) + + @classmethod + def for_agent(cls, source_file: Path, content: str) -> PlannedArtifact: + return cls( + relative_path=CODEX_AGENTS_ROOT / f"{source_file.stem}.toml", + payload=GeneratedText(content), + kind=ArtifactKind.AGENT, + ) + + @classmethod + def from_source_file( + cls, source_file: Path, relative_path: Path + ) -> PlannedArtifact: + return cls( + relative_path=relative_path, + payload=SourceCopy(source_file), + ) + + def prefixed(self, prefix: Path) -> PlannedArtifact: + return PlannedArtifact( + relative_path=prefix / self.relative_path, + payload=self.payload, + kind=self.kind, + ) + + def without_prefix(self) -> PlannedArtifact: + return PlannedArtifact( + relative_path=Path(*self.relative_path.parts[1:]), + payload=self.payload, + kind=self.kind, + ) + +@dataclass +class MigrationSummary: + instructions: int = 0 + skills: int = 0 + subagents: int = 0 + mcp_servers: int = 0 + orphaned_skills: int = 0 + orphaned_subagents: int = 0 + + def add(self, other: MigrationSummary) -> None: + for summary_field in dataclass_fields(self): + field_name = summary_field.name + setattr( + self, + field_name, + getattr(self, field_name) + getattr(other, field_name), + ) + + def render(self, deploy_mode: object, dry_run: bool) -> str: + suffix = " (dry-run)" if dry_run else "" + deploy_mode_value = getattr(deploy_mode, "value", str(deploy_mode)) + lines = [ + f"Migration summary{suffix}:", + f" deploy mode: {deploy_mode_value}", + ] + for summary_field in dataclass_fields(self): + field_name = summary_field.name + value = getattr(self, field_name) + label = SUMMARY_LABELS.get(field_name, field_name.replace("_", " ")) + lines.append(f" {label}: {value}") + return "\n".join(lines) + + +@dataclass +class ConversionResult: + summary: MigrationSummary = field(default_factory=MigrationSummary) + artifacts: list[PlannedArtifact] = field(default_factory=list) + report_items: list[MigrationReportItem] = field(default_factory=list) + + def add(self, other: ConversionResult) -> None: + self.summary.add(other.summary) + self.artifacts.extend(other.artifacts) + self.report_items.extend(other.report_items) + + def prefixed(self, prefix: Path) -> ConversionResult: + return ConversionResult( + summary=self.summary, + artifacts=[artifact.prefixed(prefix) for artifact in self.artifacts], + report_items=[ + MigrationReportItem( + item.status, + prefix / item.path, + item.detail, + ) + for item in self.report_items + ], + ) + + +def json_object(value: object) -> Mapping[str, object]: + if isinstance(value, Mapping): + return value + return {} + + +def json_string(value: object) -> str | None: + if value is None: + return None + return str(value) + + +def json_string_tuple(value: object) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return tuple(str(item) for item in value) + return (str(value),) + + +def load_scope_settings(scope_root: Path) -> Mapping[str, object]: + settings: dict[str, object] = {} + for rel in CLAUDE_SETTINGS_JSON_RELATIVE: + outcome = read_json_mapping_file(scope_root / rel) + if outcome.exists and outcome.ok: + settings.update(json_object(outcome.data)) + return settings + + +def format_bullets(values: Sequence[str], prefix: str = "") -> str: + return "\n".join(f"- {prefix}{value}" for value in values) + + +def format_manual_migration_block(notes: Sequence[str]) -> str: + return "## MANUAL MIGRATION REQUIRED\n\n" + "\n\n".join( + note.rstrip() for note in notes if note.strip() + ) + + +def unsupported_frontmatter_fields( + frontmatter_values: Mapping[str, YamlValue], + supported_fields: Sequence[str], +) -> tuple[str, ...]: + supported = frozenset(supported_fields) + return tuple( + sorted( + field_name + for field_name in frontmatter_values + if field_name not in supported + ) + ) + + +def append_report_item( + report_items: list[MigrationReportItem], + requires_manual_fix: object, + path: Path, + manual_detail: str, + rewritten_detail: str, +) -> None: + if requires_manual_fix: + report_items.append(manual_report_item(path, manual_detail)) + return + report_items.append(MigrationReportItem("rewritten", path, rewritten_detail)) + + +def manual_report_item(path: Path, detail: str) -> MigrationReportItem: + return MigrationReportItem("manual_fix_required", path, detail) + + +def report_manual_paths( + scope: ScopePaths, + path_labels: Sequence[tuple[Path, str]], +) -> ConversionResult: + result = ConversionResult() + + for relative_path, label in path_labels: + if path_exists_with_exact_case(scope.source / relative_path): + result.report_items.append( + manual_report_item( + relative_path, + f"Manual review required for {label}; not converted by this tool.", + ) + ) + + return result + + +def path_exists_with_exact_case(path: Path) -> bool: + if not path.exists(): + return False + try: + return path.name in {child.name for child in path.parent.iterdir()} + except FileNotFoundError: + return False + + +def is_path_within_root(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def parse_frontmatter(content: str, path: Path | None = None) -> ParsedDocument: + match = FRONTMATTER_RE.match(content) + if not match: + return ParsedDocument(SimpleYamlFrontmatter({}), content, path) + + raw_frontmatter, body = match.groups() + return ParsedDocument(parse_yaml_frontmatter(raw_frontmatter, path), body, path) + + +def parse_yaml_frontmatter( + content: str, + path: Path | None = None, +) -> SimpleYamlFrontmatter: + return SimpleYamlFrontmatter(parse_yaml_mapping(content)) + + +def format_frontmatter(frontmatter: SimpleYamlFrontmatter, body: str) -> str: + rendered = format_yaml_mapping(frontmatter.to_dict()) + return f"---\n{rendered}\n---\n\n{body.lstrip()}" + + +def map_model_name(model: str) -> str: + for mapping in MODEL_PREFIX_MAPPINGS: + if model.startswith(mapping.source_prefix): + return mapping.target_model + return model + + +def map_model_effort(model: str | None, effort: str) -> str: + if not model: + return effort + for mapping in MODEL_PREFIX_MAPPINGS: + if model.startswith(mapping.source_prefix): + return mapping.map_effort(effort) + return effort + + +def map_permission_mode(permission_mode: str | None) -> str | None: + if not permission_mode: + return None + return PERMISSION_MODE_MAPPINGS.get(permission_mode) diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/hooks.py b/skills/.curated/migrate-to-codex/scripts/migrate/hooks.py new file mode 100644 index 0000000..eb4f09e --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/hooks.py @@ -0,0 +1,238 @@ +"""Convert supported Claude Code hooks into Codex hook config.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + +from migrate.common import ( + ConversionResult, + GeneratedText, + MigrationReportItem, + PlannedArtifact, + ScopePaths, + json_object, + json_string, + path_exists_with_exact_case, + read_json_mapping_file, +) +from migrate.settings import CLAUDE_SETTINGS_JSON_RELATIVE + + +CODEX_HOOKS_PATH = Path(".codex") / "hooks.json" +CODEX_HOOK_EVENTS = ( + "PreToolUse", + "PostToolUse", + "SessionStart", + "UserPromptSubmit", + "Stop", +) +CODEX_HOOK_MATCHER_EVENTS = frozenset(("PreToolUse", "PostToolUse", "SessionStart")) + + +@dataclass(frozen=True) +class ClaudeHookCommand: + command: str + timeout_sec: int | None = None + status_message: str | None = None + + @classmethod + def from_mapping(cls, hook_config: Mapping[str, object]) -> ClaudeHookCommand | None: + command = json_string(hook_config.get("command")) + if command is None or not command.strip(): + return None + + timeout_value = hook_config.get("timeout") + if timeout_value is None: + timeout_value = hook_config.get("timeoutSec") + + return cls( + command=command, + timeout_sec=json_int(timeout_value), + status_message=json_string(hook_config.get("statusMessage")), + ) + + def to_mapping(self) -> dict[str, object]: + result: dict[str, object] = { + "type": "command", + "command": self.command, + } + if self.timeout_sec is not None: + result["timeout"] = self.timeout_sec + if self.status_message is not None: + result["statusMessage"] = self.status_message + return result + + +@dataclass(frozen=True) +class ClaudeHookMatcherGroup: + event_name: str + matcher: str | None + hooks: tuple[ClaudeHookCommand, ...] + + def to_mapping(self) -> dict[str, object]: + result: dict[str, object] = { + "hooks": [hook.to_mapping() for hook in self.hooks], + } + if self.matcher is not None: + result["matcher"] = self.matcher + return result + + +@dataclass(frozen=True) +class ClaudeHooks: + matcher_groups: tuple[ClaudeHookMatcherGroup, ...] = () + source_paths: tuple[Path, ...] = () + unsupported_fields: tuple[str, ...] = () + + @classmethod + def from_scope(cls, scope_root: Path) -> ClaudeHooks: + hook_sets = [ + cls.from_settings_mapping(relative_path, outcome.data) + for relative_path in CLAUDE_SETTINGS_JSON_RELATIVE + if path_exists_with_exact_case(scope_root / relative_path) + for outcome in (read_json_mapping_file(scope_root / relative_path),) + if outcome.exists and outcome.ok + ] + return cls( + matcher_groups=tuple( + matcher_group + for hook_set in hook_sets + for matcher_group in hook_set.matcher_groups + ), + source_paths=tuple( + source_path + for hook_set in hook_sets + for source_path in hook_set.source_paths + ), + unsupported_fields=tuple( + unsupported_field + for hook_set in hook_sets + for unsupported_field in hook_set.unsupported_fields + ), + ) + + @classmethod + def from_settings_mapping( + cls, + relative_path: Path, + settings: Mapping[str, object], + ) -> ClaudeHooks: + hooks_config = json_object(settings.get("hooks")) + if not hooks_config: + return cls() + + matcher_groups: list[ClaudeHookMatcherGroup] = [] + unsupported_fields: list[str] = [] + for event_name, groups_value in hooks_config.items(): + if event_name not in CODEX_HOOK_EVENTS: + unsupported_fields.append(f"hooks.{event_name}") + continue + + for group_config in json_object_tuple(groups_value): + matcher = json_string(group_config.get("matcher")) + if matcher is not None and event_name not in CODEX_HOOK_MATCHER_EVENTS: + unsupported_fields.append(f"hooks.{event_name}.matcher") + matcher = None + if "if" in group_config: + unsupported_fields.append(f"hooks.{event_name}.if") + + hook_commands: list[ClaudeHookCommand] = [] + for hook_config in json_object_tuple(group_config.get("hooks")): + hook_type = json_string(hook_config.get("type")) or "command" + if hook_type != "command": + unsupported_fields.append( + f"hooks.{event_name}.hooks[].type:{hook_type}" + ) + continue + if bool(hook_config.get("async")): + unsupported_fields.append(f"hooks.{event_name}.hooks[].async") + continue + + hook_command = ClaudeHookCommand.from_mapping(hook_config) + if hook_command is None: + unsupported_fields.append(f"hooks.{event_name}.hooks[].command") + continue + hook_commands.append(hook_command) + + if hook_commands: + matcher_groups.append( + ClaudeHookMatcherGroup( + event_name=event_name, + matcher=matcher, + hooks=tuple(hook_commands), + ) + ) + + return cls( + matcher_groups=tuple(matcher_groups), + source_paths=(relative_path,), + unsupported_fields=tuple(sorted(set(unsupported_fields))), + ) + + def render_codex_file(self) -> str: + hooks_payload: dict[str, list[dict[str, object]]] = {} + for matcher_group in self.matcher_groups: + hooks_payload.setdefault(matcher_group.event_name, []).append( + matcher_group.to_mapping() + ) + return json.dumps({"hooks": hooks_payload}, indent=2) + "\n" + + def report_detail(self) -> str: + runtime_caveats = ( + "Rewritten for Codex hooks; review behavior before relying on it. " + "Codex hooks require `[features].codex_hooks = true`, only execute " + "`command` handlers, skip `async` / `prompt` / `agent` handlers, ignore " + "`matcher` for `UserPromptSubmit` and `Stop`, and `PreToolUse` / " + "`PostToolUse` currently run for shell commands only." + ) + if not self.unsupported_fields: + return runtime_caveats + return ( + "Unsupported Claude hook fields need review: " + + ", ".join(f"`{field_name}`" for field_name in self.unsupported_fields) + + f". {runtime_caveats}" + ) + + +def has_convertible_hooks(scope_root: Path) -> bool: + return bool(ClaudeHooks.from_scope(scope_root).matcher_groups) + + +def report_hooks(scope: ScopePaths) -> ConversionResult: + claude_hooks = ClaudeHooks.from_scope(scope.source) + if not claude_hooks.matcher_groups: + return ConversionResult() + + return ConversionResult( + artifacts=[ + PlannedArtifact( + relative_path=CODEX_HOOKS_PATH, + payload=GeneratedText(claude_hooks.render_codex_file()), + ) + ], + report_items=[ + MigrationReportItem( + "rewritten", + CODEX_HOOKS_PATH, + claude_hooks.report_detail(), + ) + ], + ) + + +def json_int(value: object) -> int | None: + if value is None or isinstance(value, bool): + return None + try: + return int(str(value)) + except ValueError: + return None + + +def json_object_tuple(value: object) -> tuple[Mapping[str, object], ...]: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return tuple(json_object(item) for item in value) + return () diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/instructions.py b/skills/.curated/migrate-to-codex/scripts/migrate/instructions.py new file mode 100644 index 0000000..07ef019 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/instructions.py @@ -0,0 +1,81 @@ +"""Discover and classify source instruction files for AGENTS.md migration. + +Chooses the first supported instruction file for a project/global scope. Neutral +instruction files are safe for `AGENTS.md` symlinks; content with obvious +Claude-only lifecycle, hook, subagent, or permission assumptions is treated as +requiring a generated Codex-specific copy and manual rewrite. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from migrate.common import MigrationReportItem + + +INSTRUCTION_SOURCE_CANDIDATES = ( + Path(".claude") / "CLAUDE.md", + Path("CLAUDE.md"), + Path("claude.md"), + Path("AGENTS.md"), +) + +CLAUDE_ONLY_INSTRUCTION_MARKERS = ( + "/hooks", + ".claude/agents/", + ".claude/settings", + "Subagent", + "subagent", + "permissionMode", + "ExitPlanMode", +) +MAX_AGENTS_MD_BYTES = 32 * 1024 + + +def instruction_source_file( + source_root: Path, + is_global: bool, + path_exists_with_exact_case: Callable[[Path], bool], +) -> Path | None: + candidates = INSTRUCTION_SOURCE_CANDIDATES + if not is_global: + candidates = tuple( + candidate + for candidate in candidates + if candidate != Path(".claude") / "CLAUDE.md" + ) + + for candidate in candidates: + source_file = source_root / candidate + if path_exists_with_exact_case(source_file): + return source_file + return None + + +def should_symlink_instructions(content: str) -> bool: + return not any(marker in content for marker in CLAUDE_ONLY_INSTRUCTION_MARKERS) + + +def validate_agents_md_files(target_root: Path) -> list[MigrationReportItem]: + report_items: list[MigrationReportItem] = [] + for agents_file in sorted(target_root.rglob("AGENTS.md")): + relative_path = agents_file.relative_to(target_root) + size_bytes = agents_file.stat().st_size + if size_bytes > MAX_AGENTS_MD_BYTES: + report_items.append( + MigrationReportItem( + "warning", + relative_path, + f"{size_bytes / 1024:.1f}KB exceeds the 32KB review threshold.", + ) + ) + continue + report_items.append( + MigrationReportItem( + "ok", + relative_path, + f"{size_bytes / 1024:.1f}KB is under the 32KB review threshold.", + ) + ) + return report_items diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/mcps.py b/skills/.curated/migrate-to-codex/scripts/migrate/mcps.py new file mode 100644 index 0000000..1726739 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/mcps.py @@ -0,0 +1,222 @@ +"""Convert Claude Code MCP/settings JSON into Codex config TOML. + +Reads Claude settings plus `.mcp.json` / `.claude.json`, maps model and sandbox +settings when there is a known Codex equivalent, and renders MCP server entries +for `.codex/config.toml`. Header/env forms are partially normalized to Codex +`bearer_token_env_var`, `env_http_headers`, `http_headers`, `env_vars`, and +literal `env` tables. +""" + +from __future__ import annotations + +import json +import re +import shutil +from collections.abc import Mapping +from pathlib import Path + +from migrate.common import ( + CODEX_CONFIG_PATH, + MigrationReportItem, + json_object, + json_string, + json_string_tuple, + path_exists_with_exact_case, +) +from migrate.settings import CLAUDE_MCP_JSON_RELATIVE +from utils.util import TomlValue + + +ENV_VAR_RE = re.compile(r"\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}\Z") +BEARER_ENV_VAR_RE = re.compile( + r"\ABearer\s+\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}\Z" +) + + +def mcp_server_toml_table( + server_name: str, + server_config: Mapping[str, object], + enabled_servers: tuple[str, ...], + disabled_servers: frozenset[str], +) -> dict[str, TomlValue]: + table: dict[str, TomlValue] = {} + enabled = mcp_enabled_state(server_config) + if enabled is False: + table["enabled"] = False + elif enabled_servers and server_name not in enabled_servers: + table["enabled"] = False + elif server_name in disabled_servers: + table["enabled"] = False + if url := json_string(server_config.get("url")): + table["url"] = url + if command := json_string(server_config.get("command")): + table["command"] = command + if args := json_string_tuple(server_config.get("args")): + table["args"] = list(args) + if "headers" in server_config: + append_header_config(table, json_object(server_config["headers"])) + if "env" in server_config: + append_env_config(table, json_object(server_config["env"])) + return table + + +def mcp_report_items( + mcp_servers: tuple[tuple[str, dict[str, object]], ...], +) -> list[MigrationReportItem]: + report_items = [ + MigrationReportItem( + "rewritten", + CODEX_CONFIG_PATH, + f"Converted {len(mcp_servers)} MCP server entries.", + ) + ] + for server_name, server_config in mcp_servers: + notes = mcp_manual_notes(server_name, server_config) + if notes: + report_items.append( + MigrationReportItem( + "manual_fix_required", + CODEX_CONFIG_PATH, + f"MCP server `{server_name}` needs review: {' '.join(notes)}", + ) + ) + return report_items + + +def append_header_config( + table: dict[str, TomlValue], + headers: Mapping[str, object], +) -> None: + static_headers: dict[str, str] = {} + env_headers: dict[str, str] = {} + + for key, value in headers.items(): + header_value = str(value) + bearer_match = BEARER_ENV_VAR_RE.match(header_value) + if key.lower() == "authorization" and bearer_match: + table["bearer_token_env_var"] = bearer_match.group(1) + continue + + env_match = ENV_VAR_RE.match(header_value) + if env_match: + env_headers[key] = env_match.group(1) + continue + + static_headers[key] = header_value + + if static_headers: + table["http_headers"] = static_headers + if env_headers: + table["env_http_headers"] = env_headers + + +def append_env_config( + table: dict[str, TomlValue], + env: Mapping[str, object], +) -> None: + static_env: dict[str, str] = {} + env_vars: list[str] = [] + + for key, value in env.items(): + env_value = str(value) + env_match = ENV_VAR_RE.match(env_value) + if env_match and env_match.group(1) == key: + env_vars.append(key) + continue + + static_env[key] = env_value + + if env_vars: + table["env_vars"] = env_vars + if static_env: + table["env"] = static_env + + +def mcp_manual_notes( + server_name: str, + server_config: Mapping[str, object], +) -> tuple[str, ...]: + notes: list[str] = [] + source_type = json_string(server_config.get("type")) + if source_type and source_type not in {"http", "stdio"}: + notes.append( + f"Claude MCP `type: {source_type}` was not written to Codex config; verify that the generated `url` or `command` is a supported Codex transport." + ) + unsupported_fields = unsupported_mcp_server_fields(server_config) + if unsupported_fields: + notes.append( + "Review unsupported Claude MCP fields manually: " + + ", ".join(f"`{field_name}`" for field_name in unsupported_fields) + + "." + ) + return tuple(notes) + + +def mcp_enabled_state(server_config: Mapping[str, object]) -> bool | None: + if server_config.get("enabled") is False: + return False + if server_config.get("disabled") is True: + return False + return None + + +def unsupported_mcp_server_fields( + server_config: Mapping[str, object], +) -> tuple[str, ...]: + supported = { + "args", + "command", + "disabled", + "enabled", + "env", + "headers", + "name", + "scope", + "type", + "url", + } + return tuple(sorted(key for key in server_config if key not in supported)) + + +def read_claude_mcp_servers(source_root: Path) -> tuple[tuple[str, dict[str, object]], ...]: + servers: list[tuple[str, dict[str, object]]] = [] + for relative_path in CLAUDE_MCP_JSON_RELATIVE: + source_file = source_root / relative_path + if not path_exists_with_exact_case(source_file): + continue + mcp_config = json_object(json.loads(source_file.read_text())) + for server_name, server_config in json_object(mcp_config.get("mcpServers")).items(): + servers.append((server_name, json_object(server_config))) + return tuple(servers) + + +def validate_mcp_commands(config: dict[str, object]) -> list[MigrationReportItem]: + mcp_servers = config.get("mcp_servers") + if not isinstance(mcp_servers, dict): + return [] + + report_items: list[MigrationReportItem] = [] + for server_name, server_config in sorted(mcp_servers.items()): + if not isinstance(server_config, dict): + continue + command = server_config.get("command") + if not command: + continue + command_text = str(command) + if shutil.which(command_text): + report_items.append( + MigrationReportItem( + "ok", + CODEX_CONFIG_PATH, + f"MCP server `{server_name}` command `{command_text}` is on PATH.", + ) + ) + else: + report_items.append( + MigrationReportItem( + "warning", + CODEX_CONFIG_PATH, + f"MCP server `{server_name}` command `{command_text}` was not found on PATH.", + ) + ) + return report_items diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/plugins.py b/skills/.curated/migrate-to-codex/scripts/migrate/plugins.py new file mode 100644 index 0000000..b05cd0d --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/plugins.py @@ -0,0 +1,16 @@ +"""Report Claude Code plugin surfaces that need manual Codex migration. + +Claude Code plugins and plugin marketplaces can bundle commands, agents, MCP +servers, skills, and hooks with provider-specific metadata. The migrator reports +their presence as manual follow-up; it does not install Codex plugins, copy +plugin trees, or read marketplace `source` entries. +""" + +from __future__ import annotations + +from migrate.common import ConversionResult, ScopePaths, report_manual_paths +from migrate.settings import CLAUDE_PLUGIN_MANUAL_PATHS + + +def report_plugins(scope: ScopePaths) -> ConversionResult: + return report_manual_paths(scope, CLAUDE_PLUGIN_MANUAL_PATHS) diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/settings.py b/skills/.curated/migrate-to-codex/scripts/migrate/settings.py new file mode 100644 index 0000000..0e0aa07 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/settings.py @@ -0,0 +1,41 @@ +"""Shared source path constants for migration discovery/reporting. + +Constants here describe where Claude Code commonly stores instructions, +commands, skills, agents, MCP config, plugin references, and hooks. Paths are +relative to `ScopePaths.source`, the directory containing `.claude`, `.mcp.json`, +and similar source roots. +""" + +from __future__ import annotations + +from pathlib import Path + +CLAUDE_SETTINGS_JSON_RELATIVE = ( + Path(".claude") / "settings.json", + Path(".claude") / "settings.local.json", +) + +CLAUDE_MCP_JSON_RELATIVE = ( + Path(".mcp.json"), + Path(".claude.json"), +) + +CLAUDE_PLUGIN_MANUAL_PATHS = ( + (Path(".claude") / "plugins", "Claude Code plugins"), + ( + Path(".claude") / "plugin-marketplaces.json", + "Claude Code plugin marketplace registry", + ), + ( + Path(".claude-plugin") / "marketplace.json", + "Claude Code plugin marketplace", + ), +) + +SOURCE_SCAN_ROOTS = ( + (Path(".claude"), "primary source"), +) + +SOURCE_SCOPE_MARKERS = ( + Path(".claude"), +) diff --git a/skills/.curated/migrate-to-codex/scripts/migrate/skills.py b/skills/.curated/migrate-to-codex/scripts/migrate/skills.py new file mode 100644 index 0000000..0e72984 --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/migrate/skills.py @@ -0,0 +1,393 @@ +"""Convert Claude Code skills and commands into Codex skills. + +Reads `.claude/skills//SKILL.md` and `.claude/skills/.md`, then emits +`.agents/skills//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), + ) diff --git a/skills/.curated/migrate-to-codex/scripts/utils/__init__.py b/skills/.curated/migrate-to-codex/scripts/utils/__init__.py new file mode 100644 index 0000000..211a97f --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/utils/__init__.py @@ -0,0 +1 @@ +# Migration script helper modules. diff --git a/skills/.curated/migrate-to-codex/scripts/utils/scan.py b/skills/.curated/migrate-to-codex/scripts/utils/scan.py new file mode 100644 index 0000000..cc1540c --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/utils/scan.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from pathlib import Path + + +def should_skip_inventory_child(child: Path) -> bool: + return child.name in {".DS_Store", "__pycache__"} + + +def command_file_inventory( + source_root: Path, + command_file_sources: Sequence[tuple[Path, str, str]], +) -> tuple[tuple[str, tuple[str, ...]], ...]: + inventory: list[tuple[str, tuple[str, ...]]] = [] + for relative_root, _name_prefix, provider in command_file_sources: + absolute_root = source_root / relative_root + if not absolute_root.exists(): + continue + command_names = tuple( + sorted( + source_file.relative_to(absolute_root).with_suffix("").as_posix() + for source_file in absolute_root.rglob("*.md") + ) + ) + if command_names: + inventory.append((provider, command_names)) + return tuple(inventory) + + +def render_named_inventory( + lines: list[str], + label: str, + values: Sequence[str], +) -> None: + if not values: + lines.append(f" inactive: {label} - none found") + return + lines.append(f" active: {label} - {len(values)} found") + for value in values: + lines.append(f" - {value}") + + +def render_scope_inventory( + source_root: Path, + instruction_source_candidates: Sequence[Path], + command_file_sources: Sequence[tuple[Path, str, str]], + skill_source_roots: Sequence[Path], + agent_source_roots: Sequence[Path], + iter_skill_files: Callable[[Path], Sequence[Path]], + iter_agent_files: Callable[[Path], Sequence[Path]], + path_exists_with_exact_case: Callable[[Path], bool], +) -> str: + lines = ["", "Migration inventory:"] + instruction_candidates = tuple( + candidate.as_posix() + for candidate in instruction_source_candidates + if path_exists_with_exact_case(source_root / candidate) + ) + skill_names = tuple( + sorted( + { + source_file.parent.name + for relative_root in skill_source_roots + for source_file in iter_skill_files(source_root / relative_root) + } + ) + ) + agent_names = tuple( + sorted( + { + source_file.stem + for relative_root in agent_source_roots + for source_file in iter_agent_files(source_root / relative_root) + } + ) + ) + + render_named_inventory(lines, "instruction files", instruction_candidates) + render_named_inventory(lines, "skills", skill_names) + + command_inventory = command_file_inventory(source_root, command_file_sources) + if not command_inventory: + lines.append(" inactive: command sources - none found") + else: + total_commands = sum( + len(command_names) for _, command_names in command_inventory + ) + lines.append(f" active: command sources - {total_commands} found") + for provider, command_names in command_inventory: + lines.append(f" provider: {provider} ({len(command_names)})") + for command_name in command_names: + lines.append(f" - {command_name}") + + render_named_inventory(lines, "subagents", agent_names) + return "\n".join(lines) + + +def render_source_inventory( + source_root: Path, + source_scan_roots: Sequence[tuple[Path, str]], + path_exists_with_exact_case: Callable[[Path], bool], +) -> str: + lines = ["", "Source inventory:"] + discovered = False + + for relative_root, label in source_scan_roots: + absolute_root = source_root / relative_root + if not path_exists_with_exact_case(absolute_root): + continue + discovered = True + lines.append(f" detected: {relative_root.as_posix()} - {label}") + try: + children = sorted( + absolute_root.iterdir(), key=lambda child: child.name.lower() + ) + except FileNotFoundError: + continue + for child in children: + if should_skip_inventory_child(child): + continue + child_kind = "dir" if child.is_dir() else "file" + lines.append(f" {child_kind}: {(relative_root / child.name).as_posix()}") + + if not discovered: + lines.append(" inactive: No supported source directories found.") + + return "\n".join(lines) diff --git a/skills/.curated/migrate-to-codex/scripts/utils/util.py b/skills/.curated/migrate-to-codex/scripts/utils/util.py new file mode 100644 index 0000000..c6e0b0b --- /dev/null +++ b/skills/.curated/migrate-to-codex/scripts/utils/util.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import glob +import json +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TypeAlias + + +YamlScalar: TypeAlias = str | bool | int | float | None +YamlValue: TypeAlias = YamlScalar | Sequence[YamlScalar] +TomlScalar: TypeAlias = str | bool | int | float | None +TomlValue: TypeAlias = object + + +def detected_json_keys(content: str, keys: Sequence[str]) -> tuple[str, ...]: + return tuple(key for key in keys if re.search(rf'"{re.escape(key)}"\s*:', content)) + + +def strip_jsonc_comments(content: str) -> str: + lines: list[str] = [] + for line in content.splitlines(): + in_string = False + escaped = False + result: list[str] = [] + index = 0 + while index < len(line): + char = line[index] + if escaped: + result.append(char) + escaped = False + index += 1 + continue + if char == "\\" and in_string: + result.append(char) + escaped = True + index += 1 + continue + if char == '"': + in_string = not in_string + result.append(char) + index += 1 + continue + if ( + not in_string + and char == "/" + and index + 1 < len(line) + and line[index + 1] == "/" + ): + break + result.append(char) + index += 1 + lines.append("".join(result)) + return "\n".join(lines) + + +def load_jsonc_object(content: str, json_object: callable) -> Mapping[str, object]: + without_comments = strip_jsonc_comments(content) + without_trailing_commas = re.sub(r",\s*([}\]])", r"\1", without_comments) + return json_object(json.loads(without_trailing_commas)) + + +def parse_jsonc_mapping_text(text: str) -> Mapping[str, object] | None: + """Return the top-level JSON object, or None if the text is not a JSON object.""" + try: + without_comments = strip_jsonc_comments(text) + without_trailing_commas = re.sub(r",\s*([}\]])", r"\1", without_comments) + parsed = json.loads(without_trailing_commas) + except (json.JSONDecodeError, TypeError, ValueError): + return None + if isinstance(parsed, Mapping): + return parsed + return None + + +@dataclass(frozen=True) +class JsonMappingFileRead: + exists: bool + ok: bool + data: Mapping[str, object] + + +def read_json_mapping_file(path: Path) -> JsonMappingFileRead: + """Read a JSON/JSONC file. ``ok`` is False when the file exists but could not be parsed.""" + if not path.is_file(): + return JsonMappingFileRead(exists=False, ok=True, data={}) + text = path.read_text() + parsed = parse_jsonc_mapping_text(text) + if parsed is None: + return JsonMappingFileRead(exists=True, ok=False, data={}) + return JsonMappingFileRead(exists=True, ok=True, data=parsed) + + +@dataclass(frozen=True) +class TomlMultilineString: + value: str + + +def parse_yaml_mapping(content: str) -> dict[str, YamlValue]: + """Parse the small YAML-frontmatter subset used by Claude metadata.""" + result: dict[str, YamlValue] = {} + current_key: str | None = None + + for raw_line in content.splitlines(): + if not raw_line.strip(): + continue + + if raw_line.startswith(" - ") and current_key: + current_value = result.setdefault(current_key, []) + if not isinstance(current_value, list): + current_value = [current_value] + result[current_key] = current_value + current_value.append(parse_yaml_value(raw_line[4:].strip())) + continue + + key, separator, value = raw_line.partition(":") + if not separator: + continue + + current_key = key.strip() + value = value.strip() + result[current_key] = parse_yaml_value(value) if value else [] + + return result + + +def parse_yaml_value(value: str) -> YamlValue: + if value in {"true", "True"}: + return True + if value in {"false", "False"}: + return False + if value in {"null", "Null", "~"}: + return None + if value.startswith("[") and value.endswith("]"): + return tuple( + parse_yaml_value(item) + for item in split_delimited_values(value[1:-1]) + if item + ) + if value.startswith('"') and value.endswith('"'): + try: + return json.loads(value) + except json.JSONDecodeError: + return value[1:-1] + if value.startswith("'") and value.endswith("'"): + return value[1:-1].replace("''", "'") + return value + + +def split_delimited_values(content: str) -> tuple[str, ...]: + values: list[str] = [] + token: list[str] = [] + quote: str | None = None + escaped = False + for char in content: + if escaped: + token.append(char) + escaped = False + continue + if char == "\\" and quote == '"': + token.append(char) + escaped = True + continue + if quote: + token.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + token.append(char) + quote = char + continue + if char == ",": + values.append("".join(token).strip()) + token = [] + continue + token.append(char) + values.append("".join(token).strip()) + return tuple(values) + + +def format_yaml_mapping(values: Mapping[str, YamlValue]) -> str: + return "\n".join( + f"{key}: {format_yaml_value(value)}" for key, value in values.items() + ) + + +def format_yaml_value(value: YamlValue) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return "null" + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return "[" + ", ".join(format_yaml_value(item) for item in value) + "]" + return json.dumps(str(value)) + + +def render_toml_document(values: Mapping[str, TomlValue]) -> str: + lines: list[str] = [] + append_toml_entries(lines, values) + for key, value in values.items(): + if isinstance(value, Mapping): + append_toml_table(lines, (key,), value) + return "\n".join(lines).rstrip() + "\n" + + +def append_toml_table( + lines: list[str], + path: tuple[str, ...], + values: Mapping[str, TomlValue], +) -> None: + append_blank_line(lines) + lines.append("[" + ".".join(format_toml_key(path_part) for path_part in path) + "]") + append_toml_entries(lines, values) + + for key, value in values.items(): + if isinstance(value, Mapping): + append_toml_table(lines, (*path, key), value) + + +def append_toml_entries(lines: list[str], values: Mapping[str, TomlValue]) -> None: + for key, value in values.items(): + if isinstance(value, Mapping): + continue + lines.append(f"{format_toml_key(key)} = {format_toml_value(value)}") + + +def append_blank_line(lines: list[str]) -> None: + if lines and lines[-1]: + lines.append("") + + +def format_toml_key(key: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_-]+", key): + return key + return json.dumps(key) + + +def format_toml_value(value: TomlValue) -> str: + if isinstance(value, TomlMultilineString): + return format_toml_multiline_string(value.value) + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return '""' + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return "[" + ", ".join(format_toml_value(item) for item in value) + "]" + return json.dumps(str(value)) + + +def format_toml_multiline_string(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"""', '\\"\\"\\"') + return f'"""{escaped}"""' + + +def slugify_name(value: str) -> str: + result = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip()).strip("-").lower() + return result or "migrated-command" + + +def first_markdown_heading(content: str) -> str | None: + for line in content.splitlines(): + match = re.match(r"^#\s+(.+?)\s*$", line) + if match: + return match.group(1).strip() + return None + + +def format_backtick_list(values: Sequence[str]) -> str: + if not values: + return "" + if len(values) == 1: + return f"`{values[0]}`" + return ", ".join(f"`{value}`" for value in values[:-1]) + f", and `{values[-1]}`" + + +def normalize_source_scope_root( + path: Path, source_scope_markers: Sequence[Path] +) -> Path: + resolved = path + for marker in source_scope_markers: + if resolved.parts[-len(marker.parts) :] == marker.parts: + return resolved.parents[len(marker.parts) - 1] + return resolved + + +def resolve_source_root(source: str) -> Path: + if not glob.has_magic(source): + return Path(source) + + matches = [Path(match) for match in glob.glob(source, recursive=True)] + if not matches: + raise FileNotFoundError(f"No matches for source pattern: {source}") + + for match in matches: + if ( + match.is_dir() + and (match / "global").exists() + and (match / "project").exists() + ): + return match + + static_prefix = source.split("*", 1)[0].rstrip("/") + return Path(static_prefix)