mirror of
https://github.com/openai/skills.git
synced 2026-09-11 19:47:26 +03:00
Add migrate-to-codex skill (#364)
**Summary** - Adds the public `migrate-to-codex` curated skill for migrating external agent setup into Codex-facing files. - Includes the approved skill instructions, references, and migration scripts from openai/skills-internal#84. - Places the skill at `skills/.curated/migrate-to-codex`, which is the installable public path the Codex App should reference.
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: migrate-to-codex
|
||||
description: Migrate supported instruction files, skills, agents, and MCP config into Codex project and global files.
|
||||
---
|
||||
|
||||
# Migrate to Codex
|
||||
|
||||
## Autonomy
|
||||
|
||||
Keep going until the selected migration is completely done: run the migrator, inspect the report, fix migrated Codex instructions/skills/agents/MCP config, and re-run checks without stopping to ask for confirmation of the next step. If the user has selected a target, do not ask before creating, editing, replacing, or deleting generated Codex artifacts in that target (`AGENTS.md`, `.codex/`, `.agents/`, or `~/.codex/`). Preserve unrelated existing Codex config entries in `.codex/config.toml` or `~/.codex/config.toml`, such as `notify`, `projects`, `marketplaces`, or unrelated MCP servers; do not ask about them unless they fail validation or directly conflict with the migration. Do not edit source Claude Code files (`.claude/`, `~/.claude/`, `.mcp.json`, or `.claude.json`), unrelated project code, secrets, or another repository.
|
||||
|
||||
## Migration Order
|
||||
|
||||
Run the migration in this order for each selected global or project source:
|
||||
|
||||
1. Start by using Codex's built-in TODO/task list tool. Do not create `MIGRATION_TODOS.md` or any TODO file unless the user explicitly asks. The TODO list input has a `plan` array whose items each have `step` and `status`; use statuses `pending`, `in_progress`, and `completed`. Make the TODOs specific to the selected artifacts. Use literal source → Codex target labels, for example:
|
||||
- Inspect `.claude/commands` → Codex skills/prompts
|
||||
- Inspect `.claude/agents` → `.codex/agents`
|
||||
- Inspect `.mcp.json` → `.codex/config.toml` MCP servers
|
||||
- Inspect `.claude/settings.json` hooks → `.codex/hooks.json`
|
||||
- Migrate safe selected artifacts → Codex files
|
||||
- Validate generated `.codex/config.toml`
|
||||
- Validate generated `.codex/agents`
|
||||
- Report migrated artifacts and manual-review items
|
||||
|
||||
2. Read `references/differences.md` (and refresh Codex docs if its `Docs last checked` date is old).
|
||||
|
||||
3. Scan and inspect before writing:
|
||||
- `--scan-only` lists active and inactive source surfaces.
|
||||
- `--plan` prints staged Codex artifact paths and report rows.
|
||||
- `--doctor` summarizes readiness, manual-review work, and validation risks.
|
||||
|
||||
4. Convert surfaces in the same order the CLI uses:
|
||||
- instructions: `CLAUDE.md` / `AGENTS.md` to `AGENTS.md`
|
||||
- plugins: report Claude plugin trees and marketplaces as manual migration work
|
||||
- hooks: rewrite supported Claude hooks into `.codex/hooks.json` and enable `[features].codex_hooks = true`
|
||||
- skills and commands: write Codex skills under `.agents/skills/`
|
||||
- config: write `.codex/config.toml` from Claude model/sandbox settings and MCP servers, including `personality = "friendly"` when config is generated
|
||||
- subagents: write Codex custom agents under `.codex/agents/`
|
||||
|
||||
5. Dry-run, then write the selected target. Use `--replace` only when orphan generated skills or agents should be deleted.
|
||||
|
||||
6. Inspect the terminal output and `.codex/migrate-to-codex-report.txt` after real runs.
|
||||
|
||||
7. Review generated artifacts in this order: `AGENTS.md`, `.agents/skills/`, `.codex/config.toml`, `.codex/hooks.json`, `.codex/agents/`, then report-only plugin items.
|
||||
|
||||
8. Run `--validate-target` against each target after edits.
|
||||
|
||||
9. Re-run checks and `--dry-run` after edits.
|
||||
|
||||
10. Return the final migration report as one markdown table per scope that has rows. The tables cover only the non-native follow-up migration work you performed, such as skills created from slash commands, subagents, MCP servers, hooks, unsupported/local plugin notes, and manual-review caveats. Include programmatic native import rows for config, instructions, skills, or supported plugins only if you personally migrated them in this follow-up run.
|
||||
|
||||
If only one scope has rows, render only the table with no heading. If multiple scopes have rows, render one heading before each table. Use `**User Config**` for user-scope rows. For project-scope rows, use the actual project folder name as the heading, for example `**northstar-support-portal**`; do not use `Current Project` as the heading. Do not add prose before or after the table output.
|
||||
|
||||
Use exactly these columns:
|
||||
|
||||
**northstar-support-portal**
|
||||
|
||||
| Status | Item | Notes |
|
||||
| --- | --- | --- |
|
||||
| `Added` | `Slash command` pr-review | Converted into a Codex skill |
|
||||
| `Added` | `Subagent` release-lead | Added as a Codex subagent |
|
||||
| `Check before using` | `Hook` PreToolUse | Converted, but some Claude hook behavior differs in Codex |
|
||||
| `Not Added` | `Hook` Notification | Codex does not have an equivalent notification hook |
|
||||
| `Not Added` | `Plugin` team-macros | Plugin needs manual setup |
|
||||
|
||||
`Status` must be `Added`, `Check before using`, or `Not Added`. Use `Added` when a Codex-facing artifact was created or changed and needs no special review. Use `Check before using` when a Codex-facing artifact was created or changed but the migration changed semantics, inferred behavior, preserved tool rules as guidance, or dropped unsupported behavior. Use `Not Added` when a source artifact was detected but no Codex-facing artifact was created. `Item` combines the artifact type and concrete item name in one cell. Artifact type must be singular: `Skill`, `Slash command`, `Subagent`, `MCP`, `Hook`, or `Plugin`. Wrap the artifact type in inline code; write the item name as plain text after it. `Notes` is always required; never leave it empty. Keep notes short, plain, and literal. Avoid internal implementation terms such as runtime expansion. Prefer phrases like `Converted into a Codex skill`, `Added as a Codex subagent`, `Added to Codex config`, `Converted into a Codex hook`, `Converted, but some Claude hook behavior differs in Codex`, `Codex does not have an equivalent notification hook`, `Plugin needs manual setup`, or `Plugin marketplace needs manual setup`.
|
||||
|
||||
## Self-Healing Loop
|
||||
|
||||
Keep looping until the selected migration is complete:
|
||||
|
||||
1. Run `--plan` or `--doctor`.
|
||||
2. Run the migration with `--dry-run`.
|
||||
3. Run the migration for real.
|
||||
4. Fix every generated `## MANUAL MIGRATION REQUIRED` block and every `manual_fix_required` or `skipped` report row that can be resolved inside Codex artifacts.
|
||||
5. Run `--validate-target`.
|
||||
6. Re-run the migrator and validator until the report and validator have no actionable generated-artifact fixes left.
|
||||
|
||||
Do not edit source Claude Code files, unrelated project code, secrets, or another repository during this loop. If a report row requires source-provider changes or product judgment, leave the generated Codex artifact with clear manual guidance instead of changing the source.
|
||||
|
||||
## Commands
|
||||
|
||||
Choose the migrator command.
|
||||
|
||||
```bash
|
||||
MIGRATE_TO_CODEX='python3 .codex/skills/migrate-to-codex/scripts/migrate-to-codex.py'
|
||||
```
|
||||
|
||||
Inspect the migration before writing.
|
||||
|
||||
```bash
|
||||
$MIGRATE_TO_CODEX --source ~/.claude/ --scan-only
|
||||
$MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --plan
|
||||
$MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --doctor
|
||||
```
|
||||
|
||||
Dry-run, then run without `--dry-run`, for global and project.
|
||||
|
||||
```bash
|
||||
$MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/ --dry-run
|
||||
$MIGRATE_TO_CODEX --source ~/.claude/ --target ~/.codex/
|
||||
$MIGRATE_TO_CODEX --source ./.claude/ --target ./.codex/ --dry-run
|
||||
$MIGRATE_TO_CODEX --source ./.claude/ --target ./.codex/
|
||||
```
|
||||
|
||||
Run the post-migration validator against each target after edits.
|
||||
|
||||
```bash
|
||||
$MIGRATE_TO_CODEX --validate-target ~/.codex/
|
||||
$MIGRATE_TO_CODEX --validate-target ./.codex/
|
||||
```
|
||||
|
||||
Run `$MIGRATE_TO_CODEX --help` for flags (`--scan-only`, `--plan`, `--doctor`, `--validate-target`, defaults, and so on). Deep tables and more links are in `references/differences.md`.
|
||||
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "Migrate to Codex"
|
||||
short_description: "Migrate supported instruction files, skills, agents, and config into Codex"
|
||||
@@ -0,0 +1,167 @@
|
||||
# Migration Differences
|
||||
|
||||
## Summary
|
||||
|
||||
This reference covers Claude Code to Codex migration only. It lists migration differences, partial mappings, and unsupported Claude Code behavior. Direct 1:1 mappings are intentionally omitted. When the converter preserves Claude-only semantics as prompt guidance, it also emits a `manual_fix_required` report row and writes a `## MANUAL MIGRATION REQUIRED` block into the generated file.
|
||||
|
||||
Docs last checked: 2026-04-20. If today's date is later, re-open the official Codex docs below and the Claude Code docs map before trusting these mappings.
|
||||
|
||||
## Instructions
|
||||
|
||||
| Source | Codex | Migration behavior | Caveat |
|
||||
| --- | --- | --- | --- |
|
||||
| `.claude/CLAUDE.md`, `CLAUDE.md`, or `claude.md` | `AGENTS.md` symlink | Linked automatically when content looks provider-neutral | This keeps one shared instruction body instead of duplicating docs. |
|
||||
| Root `AGENTS.md` | Root `AGENTS.md` | Reported as active | The converter does not overwrite or symlink the target file to itself. |
|
||||
| Instruction content with `/hooks`, `.claude/agents/`, settings paths, subagent language, or permission-mode assumptions | Generated `AGENTS.md` copy | Manual rewrite pass | The converter intentionally breaks the symlink when obvious Claude-only semantics need a Codex-specific edit. |
|
||||
|
||||
## Commands
|
||||
|
||||
| Source | Codex | Migration behavior | Caveat |
|
||||
| --- | --- | --- | --- |
|
||||
| `.claude/commands/*.md` | `.agents/skills/source-command-<name>/SKILL.md` | Converted to one-file Codex skills | Slash-command invocation, `argument-hint`, `allowed-tools`, `$ARGUMENTS`, shell-output interpolation, and file-reference expansion are preserved as manual-review text. |
|
||||
| Command files with runtime expansion | One-file Codex skills plus `manual_fix_required` rows | Preserved as prompt text | Argument placeholders, shell-output interpolation, automatic file expansion, model/agent routing, and executable hook behavior have different runtime behavior and must be checked manually. |
|
||||
|
||||
## Skills
|
||||
|
||||
| Source | Codex | Migration behavior | Caveat |
|
||||
| --- | --- | --- | --- |
|
||||
| `.claude/skills/<name>/SKILL.md` | `.agents/skills/<name>/SKILL.md` | Converted; selected support directories are copied | Skill-local `scripts/`, `references/`, and `assets/` are copied when they are real files under the source skill root. |
|
||||
| `.claude/skills/<name>.md` | `.agents/skills/<name>/SKILL.md` | Converted as a single-file skill | No sibling support directories are copied for this legacy shape. |
|
||||
| `allowed-tools` | No strict skill allowlist | Preserved as prompt guidance in `SKILL.md` | `agents/openai.yaml` can declare tool dependencies, but that is not a permission boundary. |
|
||||
| `user-invocable` | `policy.allow_implicit_invocation` | Manual review only | Similar intent, not equivalent semantics. |
|
||||
| `model` / `effort` | No skill-level model pin | Unsupported | Codex model selection is session/agent scoped in this converter. |
|
||||
| `disable-model-invocation` | No direct equivalent | Unsupported | Requires a manual rewrite if the source skill depends on this behavior. |
|
||||
| `argument-hint` / `context` / `agent` / `hooks` / `paths` / `shell` | No direct equivalent | Unsupported | Keep only if the behavior can be rewritten into prompt guidance or config. |
|
||||
|
||||
## MCP and config
|
||||
|
||||
| Source | Codex | Migration behavior | Caveat |
|
||||
| --- | --- | --- | --- |
|
||||
| `.mcp.json` or `.claude.json` `mcpServers` | `.codex/config.toml` `[mcp_servers.<name>]` | Converted | Project `.mcp.json` and global `.claude.json` use the same source shape for this migrator. Codex supports additional MCP server fields such as `cwd`, `enabled_tools`, `disabled_tools`, and timeout settings, but this converter only writes fields that map clearly from Claude source config. |
|
||||
| Claude Code model/sandbox settings or MCP config | `personality = "friendly"` | Written when the migrator generates `.codex/config.toml` | Codex supports `none`, `friendly`, and `pragmatic`; Claude Code migrations default to friendly to preserve a warm assistant style. |
|
||||
| `type: sse` | No SSE support | Unsupported | Codex supports stdio and streamable HTTP in current docs. |
|
||||
| `headers.Authorization: Bearer ${VAR}` | `bearer_token_env_var` | Direct auth rewrite | Only the bearer-token shape is rewritten this way; `${VAR:-default}` fallbacks are not preserved. |
|
||||
| `headers` with `${VAR}` | `env_http_headers` | Partial mapping | Static headers map to `http_headers`; `${VAR:-default}` fallbacks are not preserved. |
|
||||
| `env` with `${VAR}` | `env_vars` | Partial mapping | Literal values stay in `env`; self-references become `env_vars`, and `${VAR:-default}` fallbacks are not preserved. |
|
||||
| `oauth.callbackPort` | `mcp_oauth_callback_port` | Manual review only | `oauth.clientId`, `oauth.authServerMetadataUrl`, and `headersHelper` are unsupported. |
|
||||
| `enabledMcpjsonServers` / `disabledMcpjsonServers` | Per-server `enabled` | Partial mapping | `enableAllProjectMcpServers` has no direct equivalent in this converter. |
|
||||
| `allowedMcpServers` / `deniedMcpServers` | `requirements.toml` | Manual policy mapping | Not written by this converter. |
|
||||
| `.claude/settings.local.json` | No local-only Codex equivalent | Unsupported | Codex project config is tied to trusted project behavior. |
|
||||
|
||||
## Subagents
|
||||
|
||||
| Source | Codex | Migration behavior | Caveat |
|
||||
| --- | --- | --- | --- |
|
||||
| `.claude/agents/*.md` | `.codex/agents/*.toml` | Converted | Missing `name` or `description` is inferred and reported for review. |
|
||||
| `tools` / `disallowedTools` | No source-style fine-grained agent permissions | Preserved as prompt guidance in `developer_instructions` | Use `sandbox_mode`, `[permissions]`, MCP tool filters, or app tool filters manually when intent is clear. |
|
||||
| `skills` | No spawn-time preload equivalent | Preserved as prompt guidance in `developer_instructions` | `skills.config` is enable/disable config, not preload behavior. |
|
||||
| `mcpServers` | Codex custom-agent `mcp_servers` or shared Codex MCP config | Manual review only | Codex custom-agent files can include MCP config, but this converter does not automatically map Claude subagent `mcpServers`. Use shared Codex MCP config or manually add agent-local `mcp_servers` when the source intent is clear. |
|
||||
| `permissionMode` | `sandbox_mode` | Partial mapping | Only `acceptEdits` and `readOnly` are mapped; `default`, `dontAsk`, `bypassPermissions`, and `plan` are preserved as manual-review prompt guidance. |
|
||||
| `model` + `effort` | `model` + `model_reasoning_effort` | Partial mapping by model family | Sonnet-family effort is biased one tier higher for coding-agent behavior; source `max` maps to Codex `xhigh`. |
|
||||
| `hooks` / `memory` / `background` / `isolation` / `maxTurns` | No direct equivalent | Unsupported | Foreground/background and resume behavior do not map cleanly to Codex custom-agent files. |
|
||||
| `initialPrompt` | No direct equivalent | Unsupported | Only applies when the agent runs as the main Claude session agent. |
|
||||
| Auto-delegation by `description` | Automatic or explicit Codex sub-agent spawning | Behavior change | Not a 1:1 match; verify generated agent descriptions manually. |
|
||||
| Independent agent permissions | Parent sandbox inheritance + runtime overrides | Behavior change | Codex custom-agent files set defaults, not hard isolation from the parent turn. |
|
||||
|
||||
## Plugin Marketplaces
|
||||
|
||||
| Source | Codex | Migration behavior | Caveat |
|
||||
| --- | --- | --- | --- |
|
||||
| `.claude/plugins/` | Codex plugins / skills / MCP servers / apps | Reported as `manual_fix_required` only | Codex plugins can bundle skills, MCP servers, and apps, but the migrator does not copy plugin trees. Migrate the plugin, bundled skills, commands, agents, hooks, and MCP config by hand. |
|
||||
| `.claude/plugin-marketplaces.json` | Codex plugin install or local plugin path | Reported as `manual_fix_required` only | Marketplace entries can point to local or remote plugin sources; the migrator does not fetch or install them. Codex marketplace metadata lives under `.agents/plugins/marketplace.json` or `~/.agents/plugins/marketplace.json`. |
|
||||
| `.claude-plugin/marketplace.json` | Codex plugin install or local plugin path | Reported as `manual_fix_required` only | Treat it as marketplace source material. Do not copy it into Codex as a legacy marketplace; adapt it to the Codex plugin marketplace layout if you keep it local. |
|
||||
| `metadata.pluginRoot` | No direct equivalent | Unsupported | Shorthand plugin sources that depend on `metadata.pluginRoot` need manual layout. |
|
||||
| Marketplace or `plugin.json` custom `skills` / `agents` paths | Codex plugin manifest and bundled skill paths | Manual review only | Codex plugins can declare bundled skills, MCP servers, and apps. Custom Claude plugin paths still need manual layout review; no automated scan. |
|
||||
| Plugin `commands/` | `.agents/skills/<name>/SKILL.md` | Manual | Treat like any other command migration if you copy files by hand. |
|
||||
| `strict`, `hooks`, `mcpServers`, `lspServers`, `outputStyles` | No direct equivalent | Unsupported | No automatic plugin config import. |
|
||||
|
||||
## Hooks
|
||||
|
||||
| Source | Codex | Migration behavior | Caveat |
|
||||
| --- | --- | --- | --- |
|
||||
| `hooks` in `~/.claude/settings.json`, `.claude/settings.json`, or `.claude/settings.local.json` | `.codex/hooks.json` + `[features].codex_hooks = true` | Partial conversion | Review behavior before relying on migrated hooks; Claude and Codex hook runtimes are not 1:1. |
|
||||
| `Notification` | `notify` | Manual rewrite only | `notify` is a turn-complete notification command, not a general lifecycle hook or approval-prompt hook. |
|
||||
| `PreToolUse` | `PreToolUse` in `.codex/hooks.json` | Partial conversion | Codex currently runs PreToolUse for shell commands only and blocks only `permissionDecision: "deny"`, legacy `decision: "block"`, or exit code `2`. |
|
||||
| `PostToolUse` | `PostToolUse` in `.codex/hooks.json` | Partial conversion | Codex currently runs PostToolUse for shell commands only; `decision: "block"` becomes model feedback, and `continue: false` stops execution. Formatting or fixups that Claude tied to `Edit`/`Write` should move to a `Stop` hook, because only Bash is matched for `PostToolUse`. |
|
||||
| `UserPromptSubmit` | `UserPromptSubmit` in `.codex/hooks.json` | Partial conversion | Codex can inject context or block a prompt, but it ignores `matcher` for this event and does not support source `if` filters. |
|
||||
| `SessionStart` | `SessionStart` in `.codex/hooks.json` | Partial conversion | Codex matches `startup` and `resume`; Claude may also expose other session flows. |
|
||||
| `Stop` | `Stop` in `.codex/hooks.json` | Partial conversion | Codex ignores `matcher` for Stop, can request a continuation prompt, and does not expose every source subagent/teammate stop lifecycle. |
|
||||
| `PermissionRequest` / `SubagentStart` / `SubagentStop` / `TaskCreated` / `TaskCompleted` / `StopFailure` / `PreCompact` / `PostCompact` / `SessionEnd` | No direct equivalent | Unsupported | Keep as manual follow-up items; Codex does not expose matching lifecycle coverage today. |
|
||||
| `type: "command"` | `type: "command"` | Partial conversion | `command`, `timeout` / `timeoutSec`, and `statusMessage` map. Empty commands are skipped by Codex. |
|
||||
| `type: "prompt"` / `type: "agent"` / `type: "http"` / `async: true` | No direct equivalent | Unsupported | Codex parses `prompt` / `agent` but skips them, and async hooks are skipped. HTTP hooks need a wrapper command. |
|
||||
| Hook `matcher` + `if` filters | Regex `matcher` only | Partial conversion | Codex keeps regex `matcher` for `PreToolUse`, `PostToolUse`, and `SessionStart` only. Source `if` filters do not map. |
|
||||
| Hooks in skills, agents, and plugins | No direct equivalent | Unsupported | Codex discovers hooks from config layers, not from skill or subagent manifests. |
|
||||
|
||||
## Planning and validation
|
||||
|
||||
| Command | Behavior | Caveat |
|
||||
| --- | --- | --- |
|
||||
| `--plan` | Prints staged migration output and generated artifact paths without writing files | Still depends on the selected source, target, and component flags. |
|
||||
| `--doctor` | Prints readiness, risk counts, and manual-review items without writing files | Static guidance only; it does not prove the migrated setup works. |
|
||||
| `--validate-target` | Validates an already migrated Codex target | Checks TOML parseability, skill frontmatter, custom-agent TOML fields, AGENTS.md size, and MCP command availability. |
|
||||
|
||||
## Minimal examples
|
||||
|
||||
Source skill metadata becomes prompt guidance:
|
||||
|
||||
```md
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Bash
|
||||
```
|
||||
|
||||
```md
|
||||
## MANUAL MIGRATION REQUIRED
|
||||
|
||||
Claude `allowed-tools` was preserved as prompt guidance, not a Codex permission boundary.
|
||||
|
||||
You're allowed to use these tools:
|
||||
|
||||
- Read
|
||||
- Bash
|
||||
```
|
||||
|
||||
Source subagent metadata becomes TOML plus prompt guidance:
|
||||
|
||||
```md
|
||||
skills:
|
||||
- release-notes
|
||||
tools:
|
||||
- Read
|
||||
disallowedTools:
|
||||
- Bash
|
||||
```
|
||||
|
||||
```toml
|
||||
sandbox_mode = "workspace-write"
|
||||
developer_instructions = """
|
||||
## Skills
|
||||
- $release-notes
|
||||
|
||||
## Tools
|
||||
You're allowed to use these tools:
|
||||
- Read
|
||||
|
||||
Don't use these tools:
|
||||
- Bash
|
||||
"""
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
- https://docs.claude.com/en/docs/claude-code/claude_code_docs_map
|
||||
- https://developers.openai.com/codex/config-reference
|
||||
- https://developers.openai.com/codex/mcp
|
||||
- https://developers.openai.com/codex/plugins/
|
||||
- https://developers.openai.com/codex/plugins/build/
|
||||
- https://developers.openai.com/codex/skills
|
||||
- https://developers.openai.com/codex/subagents
|
||||
- https://developers.openai.com/codex/hooks
|
||||
- https://code.claude.com/docs/en/skills
|
||||
- https://code.claude.com/docs/en/sub-agents
|
||||
- https://code.claude.com/docs/en/hooks
|
||||
- https://code.claude.com/docs/en/hooks-guide
|
||||
- https://code.claude.com/docs/en/mcp
|
||||
- https://code.claude.com/docs/en/settings
|
||||
- https://code.claude.com/docs/en/plugins
|
||||
- https://code.claude.com/docs/en/plugin-marketplaces
|
||||
@@ -0,0 +1,907 @@
|
||||
"""CLI orchestration for migrate-to-codex.
|
||||
|
||||
This module owns argument parsing, scan/dry-run/report rendering, deployment
|
||||
planning, and file writes. Provider-specific conversions live in
|
||||
`migrate.<surface>` modules; keep this file as the coordinator that combines
|
||||
instruction, skill, MCP, hook, plugin-report, and subagent conversion results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from migrate.agents import (
|
||||
AGENT_SOURCE_ROOTS,
|
||||
convert_agents,
|
||||
iter_agent_files,
|
||||
validate_agent_files,
|
||||
)
|
||||
from migrate.common import (
|
||||
CODEX_AGENTS_ROOT,
|
||||
CODEX_SKILLS_ROOT,
|
||||
ArtifactKind,
|
||||
ArtifactPayload,
|
||||
ConversionResult,
|
||||
GeneratedText,
|
||||
MigrationReportItem,
|
||||
MigrationSummary,
|
||||
PlannedArtifact,
|
||||
ScopePaths,
|
||||
SourceCopy,
|
||||
SourceSymlink,
|
||||
format_manual_migration_block,
|
||||
path_exists_with_exact_case,
|
||||
)
|
||||
from migrate.hooks import report_hooks
|
||||
from migrate.codex_config import convert_settings, validate_config_toml
|
||||
from migrate.instructions import (
|
||||
INSTRUCTION_SOURCE_CANDIDATES,
|
||||
instruction_source_file,
|
||||
should_symlink_instructions,
|
||||
validate_agents_md_files,
|
||||
)
|
||||
from migrate.plugins import report_plugins
|
||||
from migrate.settings import SOURCE_SCAN_ROOTS, SOURCE_SCOPE_MARKERS
|
||||
from migrate.skills import (
|
||||
COMMAND_FILE_SOURCES,
|
||||
SKILL_SOURCE_ROOTS,
|
||||
convert_skills,
|
||||
iter_skill_files,
|
||||
validate_skill_files,
|
||||
)
|
||||
from utils.scan import (
|
||||
render_scope_inventory,
|
||||
render_source_inventory,
|
||||
)
|
||||
from utils.util import normalize_source_scope_root, resolve_source_root
|
||||
|
||||
|
||||
# Constants
|
||||
|
||||
DEFAULT_COMPONENTS = frozenset(("mcp", "skills", "subagents"))
|
||||
MIGRATION_REPORT_PATH = Path(".codex") / "migrate-to-codex-report.txt"
|
||||
SCOPE_NAMES = ("global", "project")
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class DeployMode(Enum):
|
||||
MERGE = "merge"
|
||||
REPLACE = "replace"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeploymentPlan:
|
||||
artifacts: tuple[PlannedArtifact, ...]
|
||||
orphaned_skill_dirs: tuple[Path, ...]
|
||||
orphaned_agent_files: tuple[Path, ...]
|
||||
colliding_skill_dirs: tuple[Path, ...]
|
||||
colliding_agent_files: tuple[Path, ...]
|
||||
summary: MigrationSummary
|
||||
|
||||
def warning_messages(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
[
|
||||
*(
|
||||
f"warning: overwriting existing Codex skill at {collision}"
|
||||
for collision in self.colliding_skill_dirs
|
||||
),
|
||||
*(
|
||||
f"warning: overwriting existing Codex subagent at {collision}"
|
||||
for collision in self.colliding_agent_files
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopeDeployment:
|
||||
artifacts: tuple[PlannedArtifact, ...]
|
||||
target_root: Path
|
||||
components: frozenset[str] = DEFAULT_COMPONENTS
|
||||
|
||||
def planned_paths(self, artifact_kind: ArtifactKind) -> frozenset[Path]:
|
||||
return frozenset(
|
||||
artifact.relative_path.parent
|
||||
if artifact_kind == ArtifactKind.SKILL
|
||||
else artifact.relative_path
|
||||
for artifact in self.artifacts
|
||||
if artifact.kind == artifact_kind
|
||||
)
|
||||
|
||||
def existing_paths(self, root: Path, pattern: str) -> list[Path]:
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(path for path in root.glob(pattern) if path.is_dir() or path.is_file())
|
||||
|
||||
def orphaned_codex_paths(
|
||||
self,
|
||||
component: str,
|
||||
artifact_kind: ArtifactKind,
|
||||
codex_root: Path,
|
||||
pattern: str,
|
||||
) -> list[Path]:
|
||||
if component not in self.components:
|
||||
return []
|
||||
|
||||
target_root = self.target_root / codex_root
|
||||
planned_paths = self.planned_paths(artifact_kind)
|
||||
orphans: list[Path] = []
|
||||
for target_path in self.existing_paths(target_root, pattern):
|
||||
relative_path = codex_root / target_path.name
|
||||
if relative_path not in planned_paths:
|
||||
orphans.append(target_path)
|
||||
return orphans
|
||||
|
||||
def colliding_codex_paths(
|
||||
self,
|
||||
component: str,
|
||||
artifact_kind: ArtifactKind,
|
||||
codex_root: Path,
|
||||
) -> list[Path]:
|
||||
if component not in self.components or not (self.target_root / codex_root).exists():
|
||||
return []
|
||||
|
||||
collisions: list[Path] = []
|
||||
for relative_path in self.planned_paths(artifact_kind):
|
||||
target_path = self.target_root / relative_path
|
||||
if target_path.exists():
|
||||
collisions.append(target_path)
|
||||
return collisions
|
||||
|
||||
def plan(self) -> DeploymentPlan:
|
||||
orphaned_skill_dirs = tuple(
|
||||
self.orphaned_codex_paths(
|
||||
"skills",
|
||||
ArtifactKind.SKILL,
|
||||
CODEX_SKILLS_ROOT,
|
||||
"*",
|
||||
)
|
||||
)
|
||||
orphaned_agent_files = tuple(
|
||||
self.orphaned_codex_paths(
|
||||
"subagents",
|
||||
ArtifactKind.AGENT,
|
||||
CODEX_AGENTS_ROOT,
|
||||
"*.toml",
|
||||
)
|
||||
)
|
||||
colliding_skill_dirs = tuple(
|
||||
self.colliding_codex_paths(
|
||||
"skills",
|
||||
ArtifactKind.SKILL,
|
||||
CODEX_SKILLS_ROOT,
|
||||
)
|
||||
)
|
||||
colliding_agent_files = tuple(
|
||||
self.colliding_codex_paths(
|
||||
"subagents",
|
||||
ArtifactKind.AGENT,
|
||||
CODEX_AGENTS_ROOT,
|
||||
)
|
||||
)
|
||||
return DeploymentPlan(
|
||||
artifacts=self.artifacts,
|
||||
orphaned_skill_dirs=orphaned_skill_dirs,
|
||||
orphaned_agent_files=orphaned_agent_files,
|
||||
colliding_skill_dirs=colliding_skill_dirs,
|
||||
colliding_agent_files=colliding_agent_files,
|
||||
summary=MigrationSummary(
|
||||
orphaned_skills=len(orphaned_skill_dirs),
|
||||
orphaned_subagents=len(orphaned_agent_files),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationContext:
|
||||
conversion_result: ConversionResult
|
||||
deployment_plan: DeploymentPlan
|
||||
deployment_target_root: Path
|
||||
|
||||
|
||||
# Conversion orchestration
|
||||
|
||||
|
||||
def convert_tree(
|
||||
source_root: Path,
|
||||
components: frozenset[str] = DEFAULT_COMPONENTS,
|
||||
) -> ConversionResult:
|
||||
"""Convert a fixture tree containing global/ and project/ Claude scopes."""
|
||||
result = ConversionResult()
|
||||
scopes = [
|
||||
ScopePaths(source_root / "global", True),
|
||||
ScopePaths(source_root / "project", False),
|
||||
]
|
||||
|
||||
for scope_name, scope in zip(SCOPE_NAMES, scopes):
|
||||
if scope.source.exists():
|
||||
result.add(convert_scope(scope, components).prefixed(Path(scope_name)))
|
||||
|
||||
if "skills" in components:
|
||||
result.artifacts.extend(migration_skill_artifacts(source_root))
|
||||
return result
|
||||
|
||||
|
||||
def convert_scope(
|
||||
scope: ScopePaths,
|
||||
components: frozenset[str] = DEFAULT_COMPONENTS,
|
||||
) -> ConversionResult:
|
||||
result = ConversionResult()
|
||||
|
||||
result.add(convert_instructions(scope))
|
||||
result.add(report_plugins(scope))
|
||||
result.add(report_hooks(scope))
|
||||
if "skills" in components:
|
||||
result.add(convert_skills(scope.source))
|
||||
if "mcp" in components:
|
||||
result.add(convert_settings(scope))
|
||||
if "subagents" in components:
|
||||
result.add(convert_agents(scope.source))
|
||||
return result
|
||||
|
||||
|
||||
def convert_instructions(scope: ScopePaths) -> ConversionResult:
|
||||
source_file = instruction_source_file(
|
||||
scope.source,
|
||||
scope.is_global,
|
||||
path_exists_with_exact_case,
|
||||
)
|
||||
if not source_file:
|
||||
return ConversionResult()
|
||||
|
||||
content = source_file.read_text()
|
||||
payload: ArtifactPayload
|
||||
if source_file == scope.source / "AGENTS.md":
|
||||
report_item = MigrationReportItem(
|
||||
"rewritten",
|
||||
Path("AGENTS.md"),
|
||||
f"Existing Codex instructions already present at {source_file}.",
|
||||
)
|
||||
return ConversionResult(
|
||||
summary=MigrationSummary(instructions=1),
|
||||
report_items=[report_item],
|
||||
)
|
||||
if should_symlink_instructions(content):
|
||||
payload = SourceSymlink(source_file)
|
||||
report_item = MigrationReportItem(
|
||||
"symlinked",
|
||||
Path("AGENTS.md"),
|
||||
f"Linked to {source_file}.",
|
||||
)
|
||||
else:
|
||||
manual_block = format_manual_migration_block(
|
||||
(
|
||||
"Claude-only instructions were copied into `AGENTS.md`. Remove Claude hooks, slash commands, and subagent assumptions before relying on this file in Codex.",
|
||||
)
|
||||
)
|
||||
payload = GeneratedText(f"{content.rstrip()}\n\n{manual_block}\n")
|
||||
report_item = MigrationReportItem(
|
||||
"manual_fix_required",
|
||||
Path("AGENTS.md"),
|
||||
"Generated copy contains Claude-only instruction semantics.",
|
||||
)
|
||||
return ConversionResult(
|
||||
summary=MigrationSummary(instructions=1),
|
||||
artifacts=[
|
||||
PlannedArtifact(
|
||||
relative_path=Path("AGENTS.md"),
|
||||
payload=payload,
|
||||
)
|
||||
],
|
||||
report_items=[report_item],
|
||||
)
|
||||
|
||||
|
||||
def symlink_target(source_path: Path, target_path: Path) -> str:
|
||||
return os.path.relpath(source_path, target_path.parent)
|
||||
|
||||
|
||||
def has_artifact_path(
|
||||
conversion_result: ConversionResult,
|
||||
suffix: str,
|
||||
) -> bool:
|
||||
return any(
|
||||
artifact.relative_path.as_posix().endswith(suffix)
|
||||
for artifact in conversion_result.artifacts
|
||||
)
|
||||
|
||||
|
||||
def surface_line(status: str, surface: str, detail: str) -> str:
|
||||
return f" {status}: {surface} - {detail}"
|
||||
|
||||
|
||||
def render_migration_surfaces(
|
||||
conversion_result: ConversionResult,
|
||||
components: frozenset[str],
|
||||
) -> str:
|
||||
summary = conversion_result.summary
|
||||
lines = ["", "Migration surfaces:"]
|
||||
|
||||
if summary.instructions:
|
||||
lines.append(
|
||||
surface_line(
|
||||
"active",
|
||||
"AGENTS.md",
|
||||
f"{summary.instructions} instruction file(s) found.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
surface_line(
|
||||
"inactive",
|
||||
"AGENTS.md",
|
||||
"No supported instruction file found.",
|
||||
)
|
||||
)
|
||||
|
||||
if "skills" not in components:
|
||||
lines.append(surface_line("inactive", "skills", "Not selected by CLI flags."))
|
||||
elif summary.skills:
|
||||
lines.append(
|
||||
surface_line(
|
||||
"active",
|
||||
"skills",
|
||||
f"{summary.skills} skill(s) converted.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append(surface_line("inactive", "skills", "No skills found."))
|
||||
|
||||
if "mcp" not in components:
|
||||
lines.append(
|
||||
surface_line("inactive", "MCP config", "Not selected by CLI flags.")
|
||||
)
|
||||
elif has_artifact_path(conversion_result, ".codex/config.toml"):
|
||||
lines.append(
|
||||
surface_line(
|
||||
"active",
|
||||
"MCP config",
|
||||
f"{summary.mcp_servers} MCP server(s) converted into config.toml.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
surface_line(
|
||||
"inactive",
|
||||
"MCP config",
|
||||
"No settings or MCP config found.",
|
||||
)
|
||||
)
|
||||
|
||||
if "subagents" not in components:
|
||||
lines.append(
|
||||
surface_line("inactive", "subagents", "Not selected by CLI flags.")
|
||||
)
|
||||
elif summary.subagents:
|
||||
lines.append(
|
||||
surface_line(
|
||||
"active",
|
||||
"subagents",
|
||||
f"{summary.subagents} subagent(s) converted.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append(surface_line("inactive", "subagents", "No subagents found."))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_migration_report(
|
||||
report_items: Sequence[MigrationReportItem],
|
||||
deployment_plan: DeploymentPlan,
|
||||
deploy_mode: DeployMode,
|
||||
dry_run: bool,
|
||||
) -> str:
|
||||
lines = ["", "Migration report:"]
|
||||
for item in report_items:
|
||||
lines.append(f" {item.status}: {item.path.as_posix()} - {item.detail}")
|
||||
for collision in deployment_plan.colliding_skill_dirs:
|
||||
lines.append(
|
||||
f" overwritten: {collision.as_posix()} - Existing Codex skill will be replaced."
|
||||
)
|
||||
for collision in deployment_plan.colliding_agent_files:
|
||||
lines.append(
|
||||
f" overwritten: {collision.as_posix()} - Existing Codex subagent will be replaced."
|
||||
)
|
||||
if deploy_mode == DeployMode.REPLACE:
|
||||
orphan_status = "would_delete" if dry_run else "deleted"
|
||||
for orphan in deployment_plan.orphaned_skill_dirs:
|
||||
lines.append(
|
||||
f" {orphan_status}: {orphan.as_posix()} - Orphaned generated skill."
|
||||
)
|
||||
for orphan in deployment_plan.orphaned_agent_files:
|
||||
lines.append(
|
||||
f" {orphan_status}: {orphan.as_posix()} - Orphaned generated subagent."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def validate_target(target_root: Path) -> list[MigrationReportItem]:
|
||||
report_items: list[MigrationReportItem] = []
|
||||
report_items.extend(validate_config_toml(target_root))
|
||||
report_items.extend(validate_skill_files(target_root))
|
||||
report_items.extend(validate_agent_files(target_root))
|
||||
report_items.extend(validate_agents_md_files(target_root))
|
||||
return report_items
|
||||
|
||||
|
||||
def render_validation_report(report_items: list[MigrationReportItem]) -> str:
|
||||
lines = ["Validation report:"]
|
||||
if not report_items:
|
||||
lines.append(" warning: . - no Codex artifacts found to validate.")
|
||||
return "\n".join(lines)
|
||||
|
||||
for item in report_items:
|
||||
lines.append(f" {item.status}: {item.path.as_posix()} - {item.detail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_migration_report(target_root: Path, report_text: str) -> None:
|
||||
report_path = target_root / MIGRATION_REPORT_PATH
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(f"{report_text.lstrip()}\n")
|
||||
|
||||
|
||||
def write_artifact(artifact: PlannedArtifact, target_root: Path) -> None:
|
||||
target_path = target_root / artifact.relative_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if isinstance(artifact.payload, GeneratedText):
|
||||
if target_path.is_symlink():
|
||||
target_path.unlink()
|
||||
target_path.write_text(artifact.payload.content)
|
||||
return
|
||||
|
||||
if isinstance(artifact.payload, SourceSymlink):
|
||||
if target_path.exists() or target_path.is_symlink():
|
||||
target_path.unlink()
|
||||
target_path.symlink_to(symlink_target(artifact.payload.source_path, target_path))
|
||||
return
|
||||
|
||||
if target_path.is_symlink():
|
||||
target_path.unlink()
|
||||
shutil.copy2(artifact.payload.source_path, target_path)
|
||||
|
||||
|
||||
# Deployment orchestration
|
||||
|
||||
|
||||
def deploy_tree(
|
||||
conversion_result: ConversionResult,
|
||||
target_root: Path,
|
||||
components: frozenset[str] = DEFAULT_COMPONENTS,
|
||||
) -> DeploymentPlan:
|
||||
summary = MigrationSummary()
|
||||
artifacts: list[PlannedArtifact] = []
|
||||
orphaned_skill_dirs: list[Path] = []
|
||||
orphaned_agent_files: list[Path] = []
|
||||
colliding_skill_dirs: list[Path] = []
|
||||
colliding_agent_files: list[Path] = []
|
||||
for scope_name in SCOPE_NAMES:
|
||||
prefixed_scope_artifacts = tuple(
|
||||
artifact
|
||||
for artifact in conversion_result.artifacts
|
||||
if artifact.relative_path.parts
|
||||
and artifact.relative_path.parts[0] == scope_name
|
||||
)
|
||||
scope_artifacts = tuple(
|
||||
artifact.without_prefix() for artifact in prefixed_scope_artifacts
|
||||
)
|
||||
if not scope_artifacts:
|
||||
continue
|
||||
scope_plan = ScopeDeployment(
|
||||
scope_artifacts,
|
||||
target_root / scope_name,
|
||||
components,
|
||||
).plan()
|
||||
summary.add(scope_plan.summary)
|
||||
artifacts.extend(prefixed_scope_artifacts)
|
||||
orphaned_skill_dirs.extend(scope_plan.orphaned_skill_dirs)
|
||||
orphaned_agent_files.extend(scope_plan.orphaned_agent_files)
|
||||
colliding_skill_dirs.extend(scope_plan.colliding_skill_dirs)
|
||||
colliding_agent_files.extend(scope_plan.colliding_agent_files)
|
||||
return DeploymentPlan(
|
||||
artifacts=tuple(artifacts),
|
||||
orphaned_skill_dirs=tuple(orphaned_skill_dirs),
|
||||
orphaned_agent_files=tuple(orphaned_agent_files),
|
||||
colliding_skill_dirs=tuple(colliding_skill_dirs),
|
||||
colliding_agent_files=tuple(colliding_agent_files),
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def migration_skill_artifacts(source_root: Path) -> list[PlannedArtifact]:
|
||||
artifacts: list[PlannedArtifact] = []
|
||||
for scope_name in SCOPE_NAMES:
|
||||
if not (source_root / scope_name).exists():
|
||||
continue
|
||||
artifacts.append(
|
||||
PlannedArtifact(
|
||||
relative_path=Path(scope_name)
|
||||
/ ".agents"
|
||||
/ "skills"
|
||||
/ "migrate-to-codex"
|
||||
/ "SKILL.md",
|
||||
payload=SourceCopy(SKILL_ROOT / "SKILL.md"),
|
||||
kind=ArtifactKind.SKILL,
|
||||
)
|
||||
)
|
||||
artifacts.append(
|
||||
PlannedArtifact.from_source_file(
|
||||
SKILL_ROOT / "references" / "differences.md",
|
||||
Path(scope_name)
|
||||
/ ".agents"
|
||||
/ "skills"
|
||||
/ "migrate-to-codex"
|
||||
/ "references"
|
||||
/ "differences.md",
|
||||
)
|
||||
)
|
||||
return artifacts
|
||||
|
||||
|
||||
def render_migration_inventory(source_root: Path) -> str:
|
||||
return render_scope_inventory(
|
||||
source_root,
|
||||
INSTRUCTION_SOURCE_CANDIDATES,
|
||||
COMMAND_FILE_SOURCES,
|
||||
SKILL_SOURCE_ROOTS,
|
||||
AGENT_SOURCE_ROOTS,
|
||||
iter_skill_files,
|
||||
iter_agent_files,
|
||||
path_exists_with_exact_case,
|
||||
)
|
||||
|
||||
|
||||
def render_source_inventory_for_scope(source_root: Path) -> str:
|
||||
return render_source_inventory(
|
||||
source_root,
|
||||
SOURCE_SCAN_ROOTS,
|
||||
path_exists_with_exact_case,
|
||||
)
|
||||
|
||||
|
||||
def normalize_scope_root(path: Path, marker: str) -> Path:
|
||||
if path.name == marker:
|
||||
return path.parent
|
||||
return path
|
||||
|
||||
|
||||
def selected_components(args: argparse.Namespace) -> frozenset[str]:
|
||||
components = {
|
||||
component
|
||||
for component in ("mcp", "skills", "subagents")
|
||||
if getattr(args, component, False)
|
||||
}
|
||||
if not components:
|
||||
return DEFAULT_COMPONENTS
|
||||
return frozenset(components)
|
||||
|
||||
|
||||
def build_migration_context(
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
components: frozenset[str],
|
||||
) -> MigrationContext:
|
||||
if (source_root / "global").exists() and (source_root / "project").exists():
|
||||
conversion_result = convert_tree(source_root, components)
|
||||
deployment_target_root = target_root
|
||||
deployment_plan = deploy_tree(
|
||||
conversion_result,
|
||||
deployment_target_root,
|
||||
components,
|
||||
)
|
||||
return MigrationContext(
|
||||
conversion_result,
|
||||
deployment_plan,
|
||||
deployment_target_root,
|
||||
)
|
||||
|
||||
source_scope_root = normalize_scope_root(source_root, ".claude")
|
||||
deployment_target_root = normalize_scope_root(target_root, ".codex")
|
||||
scope = ScopePaths(
|
||||
source_scope_root,
|
||||
source_scope_root == Path.home(),
|
||||
)
|
||||
conversion_result = convert_scope(scope, components)
|
||||
deployment_plan = ScopeDeployment(
|
||||
tuple(conversion_result.artifacts),
|
||||
deployment_target_root,
|
||||
components,
|
||||
).plan()
|
||||
return MigrationContext(conversion_result, deployment_plan, deployment_target_root)
|
||||
|
||||
|
||||
def render_migration_plan(
|
||||
conversion_result: ConversionResult,
|
||||
deployment_plan: DeploymentPlan,
|
||||
deploy_mode: DeployMode,
|
||||
) -> str:
|
||||
lines = [
|
||||
"Migration plan:",
|
||||
f" deploy mode: {deploy_mode.value}",
|
||||
]
|
||||
summary = conversion_result.summary
|
||||
if summary.instructions:
|
||||
lines.append(f" stage: instructions - {summary.instructions} AGENTS.md file(s).")
|
||||
if summary.mcp_servers:
|
||||
lines.append(f" stage: mcp - {summary.mcp_servers} MCP server(s).")
|
||||
if summary.skills:
|
||||
lines.append(f" stage: skills - {summary.skills} Codex skill artifact(s).")
|
||||
if summary.subagents:
|
||||
lines.append(f" stage: subagents - {summary.subagents} Codex custom agent(s).")
|
||||
if not any(
|
||||
(
|
||||
summary.instructions,
|
||||
summary.mcp_servers,
|
||||
summary.skills,
|
||||
summary.subagents,
|
||||
)
|
||||
):
|
||||
lines.append(" stage: none - no supported migration surfaces found.")
|
||||
|
||||
if conversion_result.artifacts:
|
||||
lines.append(" artifacts:")
|
||||
for artifact in sorted(
|
||||
conversion_result.artifacts,
|
||||
key=lambda planned: planned.relative_path.as_posix(),
|
||||
):
|
||||
lines.append(f" - {artifact.relative_path.as_posix()}")
|
||||
|
||||
manual_items = [
|
||||
item
|
||||
for item in conversion_result.report_items
|
||||
if item.status == "manual_fix_required"
|
||||
]
|
||||
if manual_items:
|
||||
lines.append(f" manual review: {len(manual_items)} item(s)")
|
||||
for item in manual_items:
|
||||
lines.append(f" - {item.path.as_posix()}: {item.detail}")
|
||||
else:
|
||||
lines.append(" manual review: none")
|
||||
|
||||
if deployment_plan.colliding_skill_dirs or deployment_plan.colliding_agent_files:
|
||||
lines.append(" collisions:")
|
||||
for collision in deployment_plan.colliding_skill_dirs:
|
||||
lines.append(f" - existing skill: {collision.as_posix()}")
|
||||
for collision in deployment_plan.colliding_agent_files:
|
||||
lines.append(f" - existing subagent: {collision.as_posix()}")
|
||||
|
||||
if deployment_plan.orphaned_skill_dirs or deployment_plan.orphaned_agent_files:
|
||||
lines.append(" orphan cleanup:")
|
||||
for orphan in deployment_plan.orphaned_skill_dirs:
|
||||
lines.append(f" - skill: {orphan.as_posix()}")
|
||||
for orphan in deployment_plan.orphaned_agent_files:
|
||||
lines.append(f" - subagent: {orphan.as_posix()}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_doctor_report(
|
||||
conversion_result: ConversionResult,
|
||||
deployment_plan: DeploymentPlan,
|
||||
) -> str:
|
||||
manual_items = [
|
||||
item
|
||||
for item in conversion_result.report_items
|
||||
if item.status == "manual_fix_required"
|
||||
]
|
||||
collision_count = len(deployment_plan.colliding_skill_dirs) + len(
|
||||
deployment_plan.colliding_agent_files
|
||||
)
|
||||
orphan_count = len(deployment_plan.orphaned_skill_dirs) + len(
|
||||
deployment_plan.orphaned_agent_files
|
||||
)
|
||||
risk_count = len(manual_items) + collision_count + orphan_count
|
||||
if risk_count == 0:
|
||||
readiness = "high"
|
||||
elif risk_count <= 3:
|
||||
readiness = "medium"
|
||||
else:
|
||||
readiness = "low"
|
||||
|
||||
lines = [
|
||||
"Migration doctor:",
|
||||
f" readiness: {readiness}",
|
||||
f" manual review items: {len(manual_items)}",
|
||||
f" existing Codex collisions: {collision_count}",
|
||||
f" orphaned generated artifacts: {orphan_count}",
|
||||
]
|
||||
if manual_items:
|
||||
lines.append(" risks:")
|
||||
for item in manual_items:
|
||||
lines.append(f" - {item.path.as_posix()}: {item.detail}")
|
||||
else:
|
||||
lines.append(" risks: none detected by static migration checks.")
|
||||
lines.append(" recommended flow: run --plan, run --dry-run, review manual items, migrate, then run --validate-target.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# CLI
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Claude-style source tree to Codex (--target). "
|
||||
"Omit --mcp/--skills/--subagents to run all three. "
|
||||
"See migrate-to-codex SKILL.md."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
help="Source root (optional global/ + project/ subdirs).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
help="Codex root (required for migrate, --plan, and --doctor).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mcp", action="store_true", help="Write MCP/settings to config.toml."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills", action="store_true", help="Write skills under .agents/skills."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--subagents", action="store_true", help="Write agents under .codex/agents."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scan-sources",
|
||||
action="store_true",
|
||||
help="Print source inventory before migrate.",
|
||||
)
|
||||
mode_group = parser.add_mutually_exclusive_group()
|
||||
mode_group.add_argument(
|
||||
"--scan-only", action="store_true", help="Inventory only; omit --target."
|
||||
)
|
||||
mode_group.add_argument(
|
||||
"--plan",
|
||||
action="store_true",
|
||||
help="Print staged migration plan; do not write files.",
|
||||
)
|
||||
mode_group.add_argument(
|
||||
"--doctor",
|
||||
action="store_true",
|
||||
help="Print readiness and manual-review guidance; do not write files.",
|
||||
)
|
||||
mode_group.add_argument(
|
||||
"--validate-target",
|
||||
help="Validate an existing migrated Codex target and exit.",
|
||||
)
|
||||
deploy_group = parser.add_mutually_exclusive_group()
|
||||
deploy_group.add_argument(
|
||||
"--merge",
|
||||
action="store_true",
|
||||
help="Keep orphan generated skills/agents (default).",
|
||||
)
|
||||
deploy_group.add_argument(
|
||||
"--replace",
|
||||
action="store_true",
|
||||
help="Remove orphan generated skills/agents for selected surfaces.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Print report; do not write files."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.validate_target:
|
||||
target_root = normalize_scope_root(Path(args.validate_target), ".codex")
|
||||
report_items = validate_target(target_root)
|
||||
print(render_validation_report(report_items))
|
||||
if any(item.status == "error" for item in report_items):
|
||||
raise SystemExit(1)
|
||||
return
|
||||
|
||||
if not args.source:
|
||||
parser.error("--source is required unless --validate-target is set.")
|
||||
|
||||
source_root = resolve_source_root(args.source)
|
||||
if not source_root.exists():
|
||||
normalized_candidate = normalize_source_scope_root(
|
||||
source_root,
|
||||
SOURCE_SCOPE_MARKERS,
|
||||
)
|
||||
if normalized_candidate.exists():
|
||||
source_root = normalized_candidate
|
||||
else:
|
||||
raise SystemExit(f"Missing source root: {source_root}")
|
||||
|
||||
if args.scan_only and args.target:
|
||||
parser.error("--scan-only does not use --target.")
|
||||
if not args.scan_only and not args.target:
|
||||
parser.error("--target is required unless --scan-only or --validate-target is set.")
|
||||
|
||||
if args.scan_only:
|
||||
if (source_root / "global").exists() and (source_root / "project").exists():
|
||||
print(render_source_inventory_for_scope(source_root / "global"))
|
||||
print(render_migration_inventory(source_root / "global"))
|
||||
print(render_source_inventory_for_scope(source_root / "project"))
|
||||
print(render_migration_inventory(source_root / "project"))
|
||||
else:
|
||||
normalized_source_root = normalize_source_scope_root(
|
||||
source_root,
|
||||
SOURCE_SCOPE_MARKERS,
|
||||
)
|
||||
print(render_source_inventory_for_scope(normalized_source_root))
|
||||
print(render_migration_inventory(normalized_source_root))
|
||||
return
|
||||
|
||||
target_root = Path(args.target)
|
||||
components = selected_components(args)
|
||||
deploy_mode = DeployMode.REPLACE if args.replace else DeployMode.MERGE
|
||||
|
||||
context = build_migration_context(source_root, target_root, components)
|
||||
conversion_result = context.conversion_result
|
||||
deployment_plan = context.deployment_plan
|
||||
deployment_target_root = context.deployment_target_root
|
||||
|
||||
conversion_result.summary.add(deployment_plan.summary)
|
||||
if args.plan:
|
||||
print(render_migration_plan(conversion_result, deployment_plan, deploy_mode))
|
||||
return
|
||||
if args.doctor:
|
||||
print(render_doctor_report(conversion_result, deployment_plan))
|
||||
return
|
||||
|
||||
source_inventory = ""
|
||||
migration_inventory = ""
|
||||
if args.scan_sources:
|
||||
if (source_root / "global").exists() and (source_root / "project").exists():
|
||||
source_inventory = (
|
||||
render_source_inventory_for_scope(source_root / "global")
|
||||
+ "\n"
|
||||
+ render_source_inventory_for_scope(source_root / "project")
|
||||
)
|
||||
else:
|
||||
source_inventory = render_source_inventory_for_scope(
|
||||
normalize_source_scope_root(source_root, SOURCE_SCOPE_MARKERS),
|
||||
)
|
||||
if (source_root / "global").exists() and (source_root / "project").exists():
|
||||
migration_inventory = (
|
||||
render_migration_inventory(source_root / "global")
|
||||
+ "\n"
|
||||
+ render_migration_inventory(source_root / "project")
|
||||
)
|
||||
else:
|
||||
migration_inventory = render_migration_inventory(
|
||||
normalize_source_scope_root(source_root, SOURCE_SCOPE_MARKERS),
|
||||
)
|
||||
migration_surfaces = render_migration_surfaces(conversion_result, components)
|
||||
migration_report = render_migration_report(
|
||||
conversion_result.report_items,
|
||||
deployment_plan,
|
||||
deploy_mode,
|
||||
args.dry_run,
|
||||
)
|
||||
for warning_message in deployment_plan.warning_messages():
|
||||
print(warning_message, file=sys.stderr)
|
||||
if not args.dry_run:
|
||||
for artifact in deployment_plan.artifacts:
|
||||
write_artifact(artifact, deployment_target_root)
|
||||
if deploy_mode == DeployMode.REPLACE:
|
||||
for orphan in deployment_plan.orphaned_skill_dirs:
|
||||
shutil.rmtree(orphan)
|
||||
for orphan in deployment_plan.orphaned_agent_files:
|
||||
orphan.unlink()
|
||||
write_migration_report(
|
||||
deployment_target_root,
|
||||
f"{source_inventory}{migration_inventory}{migration_surfaces}{migration_report}",
|
||||
)
|
||||
print(conversion_result.summary.render(deploy_mode, args.dry_run))
|
||||
if source_inventory:
|
||||
print(source_inventory)
|
||||
if migration_inventory:
|
||||
print(migration_inventory)
|
||||
print(migration_surfaces)
|
||||
print(migration_report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from cli import * # noqa: F403,E402
|
||||
from cli import main # noqa: E402
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Section-level migration code for migrate-to-codex.
|
||||
|
||||
Each module owns one source-to-Codex surface: instructions, skills/commands,
|
||||
subagents, MCP/config, hooks, plugin-like sources, or shared primitives.
|
||||
`cli.py` should orchestrate these modules instead of embedding conversions.
|
||||
"""
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Convert Claude Code subagents into Codex custom-agent TOML.
|
||||
|
||||
Reads `.claude/agents/*.md` files, parses their frontmatter/body, and emits
|
||||
`.codex/agents/<name>.toml` artifacts. Partially mapped metadata such as
|
||||
skills, tool allowlists, and unknown permission modes is preserved as prompt
|
||||
guidance plus a manual-review report row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from migrate.common import (
|
||||
CODEX_AGENTS_ROOT,
|
||||
ConversionResult,
|
||||
MigrationReportItem,
|
||||
ParsedDocument,
|
||||
PlannedArtifact,
|
||||
append_report_item,
|
||||
format_bullets,
|
||||
format_manual_migration_block,
|
||||
map_model_effort,
|
||||
map_model_name,
|
||||
map_permission_mode,
|
||||
unsupported_frontmatter_fields,
|
||||
)
|
||||
from utils.util import (
|
||||
TomlMultilineString,
|
||||
first_markdown_heading,
|
||||
render_toml_document,
|
||||
slugify_name,
|
||||
)
|
||||
|
||||
|
||||
AGENT_SOURCE_ROOTS = (
|
||||
Path(".claude") / "agents",
|
||||
)
|
||||
SUPPORTED_AGENT_FRONTMATTER = (
|
||||
"name",
|
||||
"description",
|
||||
"model",
|
||||
"permissionMode",
|
||||
"skills",
|
||||
"tools",
|
||||
"disallowedTools",
|
||||
"effort",
|
||||
)
|
||||
|
||||
|
||||
def iter_agent_files(source_root: Path) -> tuple[Path, ...]:
|
||||
if not source_root.exists():
|
||||
return ()
|
||||
return tuple(
|
||||
source_file
|
||||
for source_file in sorted(source_root.glob("*.md"))
|
||||
if source_file.stem != "README"
|
||||
)
|
||||
|
||||
|
||||
def agent_metadata(source_file: Path, document: ParsedDocument) -> dict[str, object]:
|
||||
inferred_fields: list[str] = []
|
||||
name = document.frontmatter.optional_string("name")
|
||||
if not name:
|
||||
name = slugify_name(source_file.stem)
|
||||
inferred_fields.append("name")
|
||||
|
||||
description = document.frontmatter.optional_string("description")
|
||||
if not description:
|
||||
heading = first_markdown_heading(document.body)
|
||||
if heading:
|
||||
description = f"Migrated Claude subagent inferred from heading `{heading}`."
|
||||
else:
|
||||
description = f"Migrated Claude subagent inferred from `{source_file.name}`."
|
||||
inferred_fields.append("description")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"model": document.frontmatter.optional_string("model"),
|
||||
"permission_mode": document.frontmatter.optional_string("permissionMode"),
|
||||
"skills": document.frontmatter.string_tuple("skills"),
|
||||
"tools": document.frontmatter.string_tuple("tools"),
|
||||
"disallowed_tools": document.frontmatter.string_tuple("disallowedTools"),
|
||||
"effort": document.frontmatter.optional_string("effort"),
|
||||
"unsupported_fields": unsupported_frontmatter_fields(
|
||||
document.frontmatter.to_dict(),
|
||||
SUPPORTED_AGENT_FRONTMATTER,
|
||||
)
|
||||
+ tuple(inferred_fields),
|
||||
}
|
||||
|
||||
|
||||
def convert_agent_file(source_file: Path) -> tuple[PlannedArtifact, MigrationReportItem]:
|
||||
document = ParsedDocument.from_file(source_file)
|
||||
metadata = agent_metadata(source_file, document)
|
||||
artifact = PlannedArtifact.for_agent(
|
||||
source_file,
|
||||
render_agent_toml(document.body, **metadata),
|
||||
)
|
||||
return artifact, agent_report_item(source_file, **metadata)
|
||||
|
||||
|
||||
def render_agent_toml(
|
||||
body: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
model: str | None,
|
||||
permission_mode: str | None,
|
||||
skills: tuple[str, ...],
|
||||
tools: tuple[str, ...],
|
||||
disallowed_tools: tuple[str, ...],
|
||||
effort: str | None,
|
||||
unsupported_fields: tuple[str, ...],
|
||||
) -> str:
|
||||
document = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
if model:
|
||||
document["model"] = map_model_name(model)
|
||||
if effort:
|
||||
document["model_reasoning_effort"] = map_model_effort(model, effort)
|
||||
sandbox_mode = map_permission_mode(permission_mode)
|
||||
if sandbox_mode:
|
||||
document["sandbox_mode"] = sandbox_mode
|
||||
|
||||
document["developer_instructions"] = TomlMultilineString(
|
||||
render_agent_body(
|
||||
body,
|
||||
permission_mode=permission_mode,
|
||||
skills=skills,
|
||||
tools=tools,
|
||||
disallowed_tools=disallowed_tools,
|
||||
unsupported_fields=unsupported_fields,
|
||||
).strip()
|
||||
)
|
||||
|
||||
return render_toml_document(document)
|
||||
|
||||
|
||||
def render_agent_body(
|
||||
body: str,
|
||||
*,
|
||||
permission_mode: str | None,
|
||||
skills: tuple[str, ...],
|
||||
tools: tuple[str, ...],
|
||||
disallowed_tools: tuple[str, ...],
|
||||
unsupported_fields: tuple[str, ...],
|
||||
) -> str:
|
||||
sections = []
|
||||
manual_notes: list[str] = []
|
||||
|
||||
sandbox_mode = map_permission_mode(permission_mode)
|
||||
if permission_mode and not sandbox_mode:
|
||||
manual_notes.append(
|
||||
f"Claude `permissionMode: {permission_mode}` has no direct Codex mapping. "
|
||||
"Manually choose `sandbox_mode`, `[permissions]`, MCP tool filters, or app tool filters before relying on this agent."
|
||||
)
|
||||
|
||||
if skills:
|
||||
sections.append(
|
||||
"## Skills\n\n"
|
||||
"You're allowed to use these skills when working on this task:\n\n"
|
||||
f"{format_bullets(skills, '$')}"
|
||||
)
|
||||
manual_notes.append(
|
||||
"Claude `skills` preload semantics were preserved as prompt guidance. Verify this agent still discovers the intended skills at runtime."
|
||||
)
|
||||
|
||||
if tools or disallowed_tools:
|
||||
tool_section_lines = [
|
||||
"## Tools",
|
||||
"",
|
||||
"Claude tool allow/deny lists were preserved as prompt guidance, not Codex permissions.",
|
||||
]
|
||||
if tools:
|
||||
tool_section_lines.extend(
|
||||
[
|
||||
"",
|
||||
"You're allowed to use these tools:",
|
||||
"",
|
||||
format_bullets(tools),
|
||||
]
|
||||
)
|
||||
if disallowed_tools:
|
||||
tool_section_lines.extend(
|
||||
[
|
||||
"",
|
||||
"Don't use these tools:",
|
||||
"",
|
||||
format_bullets(disallowed_tools),
|
||||
]
|
||||
)
|
||||
sections.append("\n".join(tool_section_lines))
|
||||
manual_notes.append(
|
||||
"Rebuild Claude `tools` / `disallowedTools` intent with Codex sandbox, MCP tool filters, or app tool filters if you need hard enforcement."
|
||||
)
|
||||
|
||||
if unsupported_fields:
|
||||
manual_notes.append(
|
||||
"Review unsupported Claude subagent fields manually: "
|
||||
f"{', '.join(f'`{field_name}`' for field_name in unsupported_fields)}."
|
||||
)
|
||||
|
||||
if manual_notes:
|
||||
sections.append(format_manual_migration_block(manual_notes))
|
||||
|
||||
if not sections:
|
||||
return body
|
||||
|
||||
joined_sections = "\n\n".join(sections)
|
||||
return f"{body.rstrip()}\n\n{joined_sections}\n"
|
||||
|
||||
|
||||
def agent_report_detail(
|
||||
*,
|
||||
permission_mode: str | None,
|
||||
skills: tuple[str, ...],
|
||||
tools: tuple[str, ...],
|
||||
disallowed_tools: tuple[str, ...],
|
||||
unsupported_fields: tuple[str, ...],
|
||||
**_: object,
|
||||
) -> str:
|
||||
caveats: list[str] = []
|
||||
if skills:
|
||||
caveats.append("skills")
|
||||
if tools:
|
||||
caveats.append("tools")
|
||||
if disallowed_tools:
|
||||
caveats.append("disallowedTools")
|
||||
if permission_mode and not map_permission_mode(permission_mode):
|
||||
caveats.append("permissionMode")
|
||||
caveats.extend(unsupported_fields)
|
||||
if not caveats:
|
||||
return "Converted Claude subagent."
|
||||
return (
|
||||
"Manual review required for Claude subagent fields: "
|
||||
+ ", ".join(f"`{field_name}`" for field_name in caveats)
|
||||
+ "."
|
||||
)
|
||||
|
||||
|
||||
def agent_report_item(
|
||||
source_file: Path,
|
||||
*,
|
||||
permission_mode: str | None,
|
||||
skills: tuple[str, ...],
|
||||
tools: tuple[str, ...],
|
||||
disallowed_tools: tuple[str, ...],
|
||||
unsupported_fields: tuple[str, ...],
|
||||
**metadata: object,
|
||||
) -> MigrationReportItem:
|
||||
report_items: list[MigrationReportItem] = []
|
||||
detail = agent_report_detail(
|
||||
permission_mode=permission_mode,
|
||||
skills=skills,
|
||||
tools=tools,
|
||||
disallowed_tools=disallowed_tools,
|
||||
unsupported_fields=unsupported_fields,
|
||||
**metadata,
|
||||
)
|
||||
append_report_item(
|
||||
report_items,
|
||||
skills
|
||||
or tools
|
||||
or disallowed_tools
|
||||
or (permission_mode and not map_permission_mode(permission_mode))
|
||||
or unsupported_fields,
|
||||
CODEX_AGENTS_ROOT / f"{source_file.stem}.toml",
|
||||
detail,
|
||||
detail,
|
||||
)
|
||||
return report_items[0]
|
||||
|
||||
|
||||
def convert_agents(source_root: Path) -> ConversionResult:
|
||||
return convert_agent_files(source_root / ".claude" / "agents")
|
||||
|
||||
|
||||
def convert_agent_files(source_root: Path) -> ConversionResult:
|
||||
result = ConversionResult()
|
||||
for source_file in iter_agent_files(source_root):
|
||||
artifact, report_item = convert_agent_file(source_file)
|
||||
result.artifacts.append(artifact)
|
||||
result.summary.subagents += 1
|
||||
result.report_items.append(report_item)
|
||||
return result
|
||||
|
||||
|
||||
def validate_agent_files(target_root: Path) -> list[MigrationReportItem]:
|
||||
agents_root = target_root / CODEX_AGENTS_ROOT
|
||||
if not agents_root.exists():
|
||||
return []
|
||||
|
||||
report_items: list[MigrationReportItem] = []
|
||||
for agent_file in sorted(agents_root.glob("*.toml")):
|
||||
relative_path = agent_file.relative_to(target_root)
|
||||
try:
|
||||
parsed = tomllib.loads(agent_file.read_text())
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
report_items.append(
|
||||
MigrationReportItem("error", relative_path, f"invalid TOML: {exc}.")
|
||||
)
|
||||
continue
|
||||
|
||||
missing = [
|
||||
key
|
||||
for key in ("name", "description", "developer_instructions")
|
||||
if not parsed.get(key)
|
||||
]
|
||||
if missing:
|
||||
report_items.append(
|
||||
MigrationReportItem(
|
||||
"error",
|
||||
relative_path,
|
||||
"agent TOML missing " + ", ".join(missing) + ".",
|
||||
)
|
||||
)
|
||||
continue
|
||||
report_items.append(
|
||||
MigrationReportItem("ok", relative_path, "agent TOML has required fields.")
|
||||
)
|
||||
return report_items
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Render Codex config from Claude Code settings and MCP inputs.
|
||||
|
||||
This module owns `.codex/config.toml` generation. It reads Claude Code
|
||||
settings for model/sandbox equivalents, asks `mcps.py` for MCP server tables,
|
||||
and adds Codex-native defaults that are not MCP-specific, such as the friendly
|
||||
personality used for Claude Code migrations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from migrate.common import (
|
||||
CODEX_CONFIG_PATH,
|
||||
ConversionResult,
|
||||
GeneratedText,
|
||||
MigrationReportItem,
|
||||
MigrationSummary,
|
||||
PlannedArtifact,
|
||||
ScopePaths,
|
||||
json_string,
|
||||
json_string_tuple,
|
||||
load_scope_settings,
|
||||
map_model_name,
|
||||
map_permission_mode,
|
||||
)
|
||||
from migrate.hooks import has_convertible_hooks
|
||||
from migrate.mcps import (
|
||||
mcp_report_items,
|
||||
mcp_server_toml_table,
|
||||
read_claude_mcp_servers,
|
||||
validate_mcp_commands,
|
||||
)
|
||||
from utils.util import TomlValue, render_toml_document
|
||||
|
||||
|
||||
DEFAULT_CODEX_PERSONALITY = "friendly"
|
||||
|
||||
|
||||
def convert_settings(scope: ScopePaths) -> ConversionResult:
|
||||
settings = load_scope_settings(scope.source)
|
||||
mcp_servers = read_claude_mcp_servers(scope.source)
|
||||
if not settings and not mcp_servers:
|
||||
return ConversionResult()
|
||||
|
||||
enabled_mcp_servers = json_string_tuple(settings.get("enabledMcpjsonServers"))
|
||||
disabled_mcp_servers = frozenset(
|
||||
json_string_tuple(settings.get("disabledMcpjsonServers"))
|
||||
)
|
||||
config_toml = render_codex_config(
|
||||
model=json_string(settings.get("model")),
|
||||
permission_mode=json_string(settings.get("permissionMode")),
|
||||
enabled_mcp_servers=enabled_mcp_servers,
|
||||
disabled_mcp_servers=disabled_mcp_servers,
|
||||
mcp_servers=mcp_servers,
|
||||
codex_hooks_enabled=has_convertible_hooks(scope.source),
|
||||
)
|
||||
if not config_toml.strip():
|
||||
return ConversionResult()
|
||||
return ConversionResult(
|
||||
summary=MigrationSummary(mcp_servers=len(mcp_servers)),
|
||||
artifacts=[
|
||||
PlannedArtifact(
|
||||
relative_path=CODEX_CONFIG_PATH,
|
||||
payload=GeneratedText(config_toml),
|
||||
)
|
||||
],
|
||||
report_items=mcp_report_items(mcp_servers),
|
||||
)
|
||||
|
||||
|
||||
def render_codex_config(
|
||||
model: str | None,
|
||||
permission_mode: str | None,
|
||||
enabled_mcp_servers: tuple[str, ...],
|
||||
disabled_mcp_servers: frozenset[str],
|
||||
mcp_servers: tuple[tuple[str, dict[str, object]], ...],
|
||||
codex_hooks_enabled: bool,
|
||||
) -> str:
|
||||
document: dict[str, TomlValue] = {}
|
||||
if model:
|
||||
document["model"] = map_model_name(model)
|
||||
sandbox_mode = map_permission_mode(permission_mode)
|
||||
if sandbox_mode:
|
||||
document["sandbox_mode"] = sandbox_mode
|
||||
|
||||
if mcp_servers:
|
||||
document["mcp_servers"] = {
|
||||
server_name: mcp_server_toml_table(
|
||||
server_name,
|
||||
server_config,
|
||||
enabled_mcp_servers,
|
||||
disabled_mcp_servers,
|
||||
)
|
||||
for server_name, server_config in mcp_servers
|
||||
}
|
||||
|
||||
if codex_hooks_enabled:
|
||||
document["features"] = {"codex_hooks": True}
|
||||
|
||||
if document:
|
||||
document = {"personality": DEFAULT_CODEX_PERSONALITY, **document}
|
||||
|
||||
return render_toml_document(document)
|
||||
|
||||
|
||||
def validate_config_toml(target_root: Path) -> list[MigrationReportItem]:
|
||||
config_path = target_root / CODEX_CONFIG_PATH
|
||||
if not config_path.exists():
|
||||
return [
|
||||
MigrationReportItem(
|
||||
"warning",
|
||||
CODEX_CONFIG_PATH,
|
||||
"not present; no Codex config to validate.",
|
||||
)
|
||||
]
|
||||
|
||||
try:
|
||||
parsed = tomllib.loads(config_path.read_text())
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
return [
|
||||
MigrationReportItem(
|
||||
"error",
|
||||
CODEX_CONFIG_PATH,
|
||||
f"invalid TOML: {exc}.",
|
||||
)
|
||||
]
|
||||
|
||||
report_items = [
|
||||
MigrationReportItem("ok", CODEX_CONFIG_PATH, "valid TOML."),
|
||||
]
|
||||
report_items.extend(validate_mcp_commands(parsed))
|
||||
return report_items
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Shared data models, frontmatter rendering, reporting, and path helpers.
|
||||
|
||||
Defines the artifact/report model used by every migration section, the
|
||||
YAML-frontmatter adapter used for skills/agents/commands, Claude-model and
|
||||
permission-mode partial mappings, and generic filesystem/report helpers. This
|
||||
module should not know about one migration surface's control flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field, fields as dataclass_fields
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TypeAlias
|
||||
|
||||
from migrate.settings import CLAUDE_SETTINGS_JSON_RELATIVE
|
||||
from utils.util import (
|
||||
format_yaml_mapping,
|
||||
parse_yaml_mapping,
|
||||
read_json_mapping_file,
|
||||
)
|
||||
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.S)
|
||||
CODEX_CONFIG_PATH = Path(".codex") / "config.toml"
|
||||
CODEX_AGENTS_ROOT = Path(".codex") / "agents"
|
||||
CODEX_SKILLS_ROOT = Path(".agents") / "skills"
|
||||
SUMMARY_LABELS = {
|
||||
"mcp_servers": "mcp servers",
|
||||
}
|
||||
PERMISSION_MODE_MAPPINGS = {
|
||||
"acceptEdits": "workspace-write",
|
||||
"readOnly": "read-only",
|
||||
}
|
||||
YamlScalar: TypeAlias = str | bool | int | float | None
|
||||
YamlValue: TypeAlias = YamlScalar | Sequence[YamlScalar]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopePaths:
|
||||
source: Path
|
||||
is_global: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelMapping:
|
||||
source_prefix: str
|
||||
target_model: str
|
||||
effort_mapping: tuple[tuple[str, str], ...]
|
||||
|
||||
def map_effort(self, effort: str) -> str:
|
||||
for source_effort, target_effort in self.effort_mapping:
|
||||
if effort == source_effort:
|
||||
return target_effort
|
||||
return effort
|
||||
|
||||
|
||||
MODEL_PREFIX_MAPPINGS = (
|
||||
ModelMapping(
|
||||
"claude-opus",
|
||||
"gpt-5.4",
|
||||
(("low", "low"), ("medium", "medium"), ("high", "high"), ("max", "xhigh")),
|
||||
),
|
||||
ModelMapping(
|
||||
"claude-sonnet",
|
||||
"gpt-5.4-mini",
|
||||
(("low", "medium"), ("medium", "high"), ("high", "xhigh"), ("max", "xhigh")),
|
||||
),
|
||||
ModelMapping(
|
||||
"claude-haiku",
|
||||
"gpt-5.4-mini",
|
||||
(("low", "low"), ("medium", "medium"), ("high", "high"), ("max", "xhigh")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ArtifactKind(Enum):
|
||||
FILE = "file"
|
||||
SKILL = "skill"
|
||||
AGENT = "agent"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeneratedText:
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceCopy:
|
||||
source_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceSymlink:
|
||||
source_path: Path
|
||||
|
||||
|
||||
ArtifactPayload: TypeAlias = GeneratedText | SourceCopy | SourceSymlink
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationReportItem:
|
||||
status: str
|
||||
path: Path
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimpleYamlFrontmatter:
|
||||
values: dict[str, YamlValue]
|
||||
|
||||
def required_string(self, key: str) -> str:
|
||||
return str(self.values[key])
|
||||
|
||||
def optional_string(self, key: str) -> str | None:
|
||||
value = self.values.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
def string_tuple(self, key: str) -> tuple[str, ...]:
|
||||
value = self.values.get(key)
|
||||
if value is None:
|
||||
return ()
|
||||
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return tuple(str(item).strip() for item in value if str(item).strip())
|
||||
|
||||
return tuple(
|
||||
split_item
|
||||
for split_item in (part.strip() for part in str(value).split(","))
|
||||
if split_item
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, YamlValue]:
|
||||
return self.values
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedDocument:
|
||||
frontmatter: SimpleYamlFrontmatter
|
||||
body: str
|
||||
path: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, source_file: Path) -> ParsedDocument:
|
||||
return parse_frontmatter(source_file.read_text(), source_file)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlannedArtifact:
|
||||
relative_path: Path
|
||||
payload: ArtifactPayload
|
||||
kind: ArtifactKind = ArtifactKind.FILE
|
||||
|
||||
@classmethod
|
||||
def for_skill(cls, source_file: Path, content: str) -> PlannedArtifact:
|
||||
return cls(
|
||||
relative_path=CODEX_SKILLS_ROOT / source_file.parent.name / "SKILL.md",
|
||||
payload=GeneratedText(content),
|
||||
kind=ArtifactKind.SKILL,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def for_agent(cls, source_file: Path, content: str) -> PlannedArtifact:
|
||||
return cls(
|
||||
relative_path=CODEX_AGENTS_ROOT / f"{source_file.stem}.toml",
|
||||
payload=GeneratedText(content),
|
||||
kind=ArtifactKind.AGENT,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_source_file(
|
||||
cls, source_file: Path, relative_path: Path
|
||||
) -> PlannedArtifact:
|
||||
return cls(
|
||||
relative_path=relative_path,
|
||||
payload=SourceCopy(source_file),
|
||||
)
|
||||
|
||||
def prefixed(self, prefix: Path) -> PlannedArtifact:
|
||||
return PlannedArtifact(
|
||||
relative_path=prefix / self.relative_path,
|
||||
payload=self.payload,
|
||||
kind=self.kind,
|
||||
)
|
||||
|
||||
def without_prefix(self) -> PlannedArtifact:
|
||||
return PlannedArtifact(
|
||||
relative_path=Path(*self.relative_path.parts[1:]),
|
||||
payload=self.payload,
|
||||
kind=self.kind,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class MigrationSummary:
|
||||
instructions: int = 0
|
||||
skills: int = 0
|
||||
subagents: int = 0
|
||||
mcp_servers: int = 0
|
||||
orphaned_skills: int = 0
|
||||
orphaned_subagents: int = 0
|
||||
|
||||
def add(self, other: MigrationSummary) -> None:
|
||||
for summary_field in dataclass_fields(self):
|
||||
field_name = summary_field.name
|
||||
setattr(
|
||||
self,
|
||||
field_name,
|
||||
getattr(self, field_name) + getattr(other, field_name),
|
||||
)
|
||||
|
||||
def render(self, deploy_mode: object, dry_run: bool) -> str:
|
||||
suffix = " (dry-run)" if dry_run else ""
|
||||
deploy_mode_value = getattr(deploy_mode, "value", str(deploy_mode))
|
||||
lines = [
|
||||
f"Migration summary{suffix}:",
|
||||
f" deploy mode: {deploy_mode_value}",
|
||||
]
|
||||
for summary_field in dataclass_fields(self):
|
||||
field_name = summary_field.name
|
||||
value = getattr(self, field_name)
|
||||
label = SUMMARY_LABELS.get(field_name, field_name.replace("_", " "))
|
||||
lines.append(f" {label}: {value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionResult:
|
||||
summary: MigrationSummary = field(default_factory=MigrationSummary)
|
||||
artifacts: list[PlannedArtifact] = field(default_factory=list)
|
||||
report_items: list[MigrationReportItem] = field(default_factory=list)
|
||||
|
||||
def add(self, other: ConversionResult) -> None:
|
||||
self.summary.add(other.summary)
|
||||
self.artifacts.extend(other.artifacts)
|
||||
self.report_items.extend(other.report_items)
|
||||
|
||||
def prefixed(self, prefix: Path) -> ConversionResult:
|
||||
return ConversionResult(
|
||||
summary=self.summary,
|
||||
artifacts=[artifact.prefixed(prefix) for artifact in self.artifacts],
|
||||
report_items=[
|
||||
MigrationReportItem(
|
||||
item.status,
|
||||
prefix / item.path,
|
||||
item.detail,
|
||||
)
|
||||
for item in self.report_items
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def json_object(value: object) -> Mapping[str, object]:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
return {}
|
||||
|
||||
|
||||
def json_string(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def json_string_tuple(value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return tuple(str(item) for item in value)
|
||||
return (str(value),)
|
||||
|
||||
|
||||
def load_scope_settings(scope_root: Path) -> Mapping[str, object]:
|
||||
settings: dict[str, object] = {}
|
||||
for rel in CLAUDE_SETTINGS_JSON_RELATIVE:
|
||||
outcome = read_json_mapping_file(scope_root / rel)
|
||||
if outcome.exists and outcome.ok:
|
||||
settings.update(json_object(outcome.data))
|
||||
return settings
|
||||
|
||||
|
||||
def format_bullets(values: Sequence[str], prefix: str = "") -> str:
|
||||
return "\n".join(f"- {prefix}{value}" for value in values)
|
||||
|
||||
|
||||
def format_manual_migration_block(notes: Sequence[str]) -> str:
|
||||
return "## MANUAL MIGRATION REQUIRED\n\n" + "\n\n".join(
|
||||
note.rstrip() for note in notes if note.strip()
|
||||
)
|
||||
|
||||
|
||||
def unsupported_frontmatter_fields(
|
||||
frontmatter_values: Mapping[str, YamlValue],
|
||||
supported_fields: Sequence[str],
|
||||
) -> tuple[str, ...]:
|
||||
supported = frozenset(supported_fields)
|
||||
return tuple(
|
||||
sorted(
|
||||
field_name
|
||||
for field_name in frontmatter_values
|
||||
if field_name not in supported
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def append_report_item(
|
||||
report_items: list[MigrationReportItem],
|
||||
requires_manual_fix: object,
|
||||
path: Path,
|
||||
manual_detail: str,
|
||||
rewritten_detail: str,
|
||||
) -> None:
|
||||
if requires_manual_fix:
|
||||
report_items.append(manual_report_item(path, manual_detail))
|
||||
return
|
||||
report_items.append(MigrationReportItem("rewritten", path, rewritten_detail))
|
||||
|
||||
|
||||
def manual_report_item(path: Path, detail: str) -> MigrationReportItem:
|
||||
return MigrationReportItem("manual_fix_required", path, detail)
|
||||
|
||||
|
||||
def report_manual_paths(
|
||||
scope: ScopePaths,
|
||||
path_labels: Sequence[tuple[Path, str]],
|
||||
) -> ConversionResult:
|
||||
result = ConversionResult()
|
||||
|
||||
for relative_path, label in path_labels:
|
||||
if path_exists_with_exact_case(scope.source / relative_path):
|
||||
result.report_items.append(
|
||||
manual_report_item(
|
||||
relative_path,
|
||||
f"Manual review required for {label}; not converted by this tool.",
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def path_exists_with_exact_case(path: Path) -> bool:
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
return path.name in {child.name for child in path.parent.iterdir()}
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def is_path_within_root(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def parse_frontmatter(content: str, path: Path | None = None) -> ParsedDocument:
|
||||
match = FRONTMATTER_RE.match(content)
|
||||
if not match:
|
||||
return ParsedDocument(SimpleYamlFrontmatter({}), content, path)
|
||||
|
||||
raw_frontmatter, body = match.groups()
|
||||
return ParsedDocument(parse_yaml_frontmatter(raw_frontmatter, path), body, path)
|
||||
|
||||
|
||||
def parse_yaml_frontmatter(
|
||||
content: str,
|
||||
path: Path | None = None,
|
||||
) -> SimpleYamlFrontmatter:
|
||||
return SimpleYamlFrontmatter(parse_yaml_mapping(content))
|
||||
|
||||
|
||||
def format_frontmatter(frontmatter: SimpleYamlFrontmatter, body: str) -> str:
|
||||
rendered = format_yaml_mapping(frontmatter.to_dict())
|
||||
return f"---\n{rendered}\n---\n\n{body.lstrip()}"
|
||||
|
||||
|
||||
def map_model_name(model: str) -> str:
|
||||
for mapping in MODEL_PREFIX_MAPPINGS:
|
||||
if model.startswith(mapping.source_prefix):
|
||||
return mapping.target_model
|
||||
return model
|
||||
|
||||
|
||||
def map_model_effort(model: str | None, effort: str) -> str:
|
||||
if not model:
|
||||
return effort
|
||||
for mapping in MODEL_PREFIX_MAPPINGS:
|
||||
if model.startswith(mapping.source_prefix):
|
||||
return mapping.map_effort(effort)
|
||||
return effort
|
||||
|
||||
|
||||
def map_permission_mode(permission_mode: str | None) -> str | None:
|
||||
if not permission_mode:
|
||||
return None
|
||||
return PERMISSION_MODE_MAPPINGS.get(permission_mode)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Convert supported Claude Code hooks into Codex hook config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from migrate.common import (
|
||||
ConversionResult,
|
||||
GeneratedText,
|
||||
MigrationReportItem,
|
||||
PlannedArtifact,
|
||||
ScopePaths,
|
||||
json_object,
|
||||
json_string,
|
||||
path_exists_with_exact_case,
|
||||
read_json_mapping_file,
|
||||
)
|
||||
from migrate.settings import CLAUDE_SETTINGS_JSON_RELATIVE
|
||||
|
||||
|
||||
CODEX_HOOKS_PATH = Path(".codex") / "hooks.json"
|
||||
CODEX_HOOK_EVENTS = (
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
)
|
||||
CODEX_HOOK_MATCHER_EVENTS = frozenset(("PreToolUse", "PostToolUse", "SessionStart"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeHookCommand:
|
||||
command: str
|
||||
timeout_sec: int | None = None
|
||||
status_message: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, hook_config: Mapping[str, object]) -> ClaudeHookCommand | None:
|
||||
command = json_string(hook_config.get("command"))
|
||||
if command is None or not command.strip():
|
||||
return None
|
||||
|
||||
timeout_value = hook_config.get("timeout")
|
||||
if timeout_value is None:
|
||||
timeout_value = hook_config.get("timeoutSec")
|
||||
|
||||
return cls(
|
||||
command=command,
|
||||
timeout_sec=json_int(timeout_value),
|
||||
status_message=json_string(hook_config.get("statusMessage")),
|
||||
)
|
||||
|
||||
def to_mapping(self) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"type": "command",
|
||||
"command": self.command,
|
||||
}
|
||||
if self.timeout_sec is not None:
|
||||
result["timeout"] = self.timeout_sec
|
||||
if self.status_message is not None:
|
||||
result["statusMessage"] = self.status_message
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeHookMatcherGroup:
|
||||
event_name: str
|
||||
matcher: str | None
|
||||
hooks: tuple[ClaudeHookCommand, ...]
|
||||
|
||||
def to_mapping(self) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"hooks": [hook.to_mapping() for hook in self.hooks],
|
||||
}
|
||||
if self.matcher is not None:
|
||||
result["matcher"] = self.matcher
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeHooks:
|
||||
matcher_groups: tuple[ClaudeHookMatcherGroup, ...] = ()
|
||||
source_paths: tuple[Path, ...] = ()
|
||||
unsupported_fields: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_scope(cls, scope_root: Path) -> ClaudeHooks:
|
||||
hook_sets = [
|
||||
cls.from_settings_mapping(relative_path, outcome.data)
|
||||
for relative_path in CLAUDE_SETTINGS_JSON_RELATIVE
|
||||
if path_exists_with_exact_case(scope_root / relative_path)
|
||||
for outcome in (read_json_mapping_file(scope_root / relative_path),)
|
||||
if outcome.exists and outcome.ok
|
||||
]
|
||||
return cls(
|
||||
matcher_groups=tuple(
|
||||
matcher_group
|
||||
for hook_set in hook_sets
|
||||
for matcher_group in hook_set.matcher_groups
|
||||
),
|
||||
source_paths=tuple(
|
||||
source_path
|
||||
for hook_set in hook_sets
|
||||
for source_path in hook_set.source_paths
|
||||
),
|
||||
unsupported_fields=tuple(
|
||||
unsupported_field
|
||||
for hook_set in hook_sets
|
||||
for unsupported_field in hook_set.unsupported_fields
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_settings_mapping(
|
||||
cls,
|
||||
relative_path: Path,
|
||||
settings: Mapping[str, object],
|
||||
) -> ClaudeHooks:
|
||||
hooks_config = json_object(settings.get("hooks"))
|
||||
if not hooks_config:
|
||||
return cls()
|
||||
|
||||
matcher_groups: list[ClaudeHookMatcherGroup] = []
|
||||
unsupported_fields: list[str] = []
|
||||
for event_name, groups_value in hooks_config.items():
|
||||
if event_name not in CODEX_HOOK_EVENTS:
|
||||
unsupported_fields.append(f"hooks.{event_name}")
|
||||
continue
|
||||
|
||||
for group_config in json_object_tuple(groups_value):
|
||||
matcher = json_string(group_config.get("matcher"))
|
||||
if matcher is not None and event_name not in CODEX_HOOK_MATCHER_EVENTS:
|
||||
unsupported_fields.append(f"hooks.{event_name}.matcher")
|
||||
matcher = None
|
||||
if "if" in group_config:
|
||||
unsupported_fields.append(f"hooks.{event_name}.if")
|
||||
|
||||
hook_commands: list[ClaudeHookCommand] = []
|
||||
for hook_config in json_object_tuple(group_config.get("hooks")):
|
||||
hook_type = json_string(hook_config.get("type")) or "command"
|
||||
if hook_type != "command":
|
||||
unsupported_fields.append(
|
||||
f"hooks.{event_name}.hooks[].type:{hook_type}"
|
||||
)
|
||||
continue
|
||||
if bool(hook_config.get("async")):
|
||||
unsupported_fields.append(f"hooks.{event_name}.hooks[].async")
|
||||
continue
|
||||
|
||||
hook_command = ClaudeHookCommand.from_mapping(hook_config)
|
||||
if hook_command is None:
|
||||
unsupported_fields.append(f"hooks.{event_name}.hooks[].command")
|
||||
continue
|
||||
hook_commands.append(hook_command)
|
||||
|
||||
if hook_commands:
|
||||
matcher_groups.append(
|
||||
ClaudeHookMatcherGroup(
|
||||
event_name=event_name,
|
||||
matcher=matcher,
|
||||
hooks=tuple(hook_commands),
|
||||
)
|
||||
)
|
||||
|
||||
return cls(
|
||||
matcher_groups=tuple(matcher_groups),
|
||||
source_paths=(relative_path,),
|
||||
unsupported_fields=tuple(sorted(set(unsupported_fields))),
|
||||
)
|
||||
|
||||
def render_codex_file(self) -> str:
|
||||
hooks_payload: dict[str, list[dict[str, object]]] = {}
|
||||
for matcher_group in self.matcher_groups:
|
||||
hooks_payload.setdefault(matcher_group.event_name, []).append(
|
||||
matcher_group.to_mapping()
|
||||
)
|
||||
return json.dumps({"hooks": hooks_payload}, indent=2) + "\n"
|
||||
|
||||
def report_detail(self) -> str:
|
||||
runtime_caveats = (
|
||||
"Rewritten for Codex hooks; review behavior before relying on it. "
|
||||
"Codex hooks require `[features].codex_hooks = true`, only execute "
|
||||
"`command` handlers, skip `async` / `prompt` / `agent` handlers, ignore "
|
||||
"`matcher` for `UserPromptSubmit` and `Stop`, and `PreToolUse` / "
|
||||
"`PostToolUse` currently run for shell commands only."
|
||||
)
|
||||
if not self.unsupported_fields:
|
||||
return runtime_caveats
|
||||
return (
|
||||
"Unsupported Claude hook fields need review: "
|
||||
+ ", ".join(f"`{field_name}`" for field_name in self.unsupported_fields)
|
||||
+ f". {runtime_caveats}"
|
||||
)
|
||||
|
||||
|
||||
def has_convertible_hooks(scope_root: Path) -> bool:
|
||||
return bool(ClaudeHooks.from_scope(scope_root).matcher_groups)
|
||||
|
||||
|
||||
def report_hooks(scope: ScopePaths) -> ConversionResult:
|
||||
claude_hooks = ClaudeHooks.from_scope(scope.source)
|
||||
if not claude_hooks.matcher_groups:
|
||||
return ConversionResult()
|
||||
|
||||
return ConversionResult(
|
||||
artifacts=[
|
||||
PlannedArtifact(
|
||||
relative_path=CODEX_HOOKS_PATH,
|
||||
payload=GeneratedText(claude_hooks.render_codex_file()),
|
||||
)
|
||||
],
|
||||
report_items=[
|
||||
MigrationReportItem(
|
||||
"rewritten",
|
||||
CODEX_HOOKS_PATH,
|
||||
claude_hooks.report_detail(),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def json_int(value: object) -> int | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return int(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def json_object_tuple(value: object) -> tuple[Mapping[str, object], ...]:
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return tuple(json_object(item) for item in value)
|
||||
return ()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Discover and classify source instruction files for AGENTS.md migration.
|
||||
|
||||
Chooses the first supported instruction file for a project/global scope. Neutral
|
||||
instruction files are safe for `AGENTS.md` symlinks; content with obvious
|
||||
Claude-only lifecycle, hook, subagent, or permission assumptions is treated as
|
||||
requiring a generated Codex-specific copy and manual rewrite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from migrate.common import MigrationReportItem
|
||||
|
||||
|
||||
INSTRUCTION_SOURCE_CANDIDATES = (
|
||||
Path(".claude") / "CLAUDE.md",
|
||||
Path("CLAUDE.md"),
|
||||
Path("claude.md"),
|
||||
Path("AGENTS.md"),
|
||||
)
|
||||
|
||||
CLAUDE_ONLY_INSTRUCTION_MARKERS = (
|
||||
"/hooks",
|
||||
".claude/agents/",
|
||||
".claude/settings",
|
||||
"Subagent",
|
||||
"subagent",
|
||||
"permissionMode",
|
||||
"ExitPlanMode",
|
||||
)
|
||||
MAX_AGENTS_MD_BYTES = 32 * 1024
|
||||
|
||||
|
||||
def instruction_source_file(
|
||||
source_root: Path,
|
||||
is_global: bool,
|
||||
path_exists_with_exact_case: Callable[[Path], bool],
|
||||
) -> Path | None:
|
||||
candidates = INSTRUCTION_SOURCE_CANDIDATES
|
||||
if not is_global:
|
||||
candidates = tuple(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if candidate != Path(".claude") / "CLAUDE.md"
|
||||
)
|
||||
|
||||
for candidate in candidates:
|
||||
source_file = source_root / candidate
|
||||
if path_exists_with_exact_case(source_file):
|
||||
return source_file
|
||||
return None
|
||||
|
||||
|
||||
def should_symlink_instructions(content: str) -> bool:
|
||||
return not any(marker in content for marker in CLAUDE_ONLY_INSTRUCTION_MARKERS)
|
||||
|
||||
|
||||
def validate_agents_md_files(target_root: Path) -> list[MigrationReportItem]:
|
||||
report_items: list[MigrationReportItem] = []
|
||||
for agents_file in sorted(target_root.rglob("AGENTS.md")):
|
||||
relative_path = agents_file.relative_to(target_root)
|
||||
size_bytes = agents_file.stat().st_size
|
||||
if size_bytes > MAX_AGENTS_MD_BYTES:
|
||||
report_items.append(
|
||||
MigrationReportItem(
|
||||
"warning",
|
||||
relative_path,
|
||||
f"{size_bytes / 1024:.1f}KB exceeds the 32KB review threshold.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
report_items.append(
|
||||
MigrationReportItem(
|
||||
"ok",
|
||||
relative_path,
|
||||
f"{size_bytes / 1024:.1f}KB is under the 32KB review threshold.",
|
||||
)
|
||||
)
|
||||
return report_items
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Convert Claude Code MCP/settings JSON into Codex config TOML.
|
||||
|
||||
Reads Claude settings plus `.mcp.json` / `.claude.json`, maps model and sandbox
|
||||
settings when there is a known Codex equivalent, and renders MCP server entries
|
||||
for `.codex/config.toml`. Header/env forms are partially normalized to Codex
|
||||
`bearer_token_env_var`, `env_http_headers`, `http_headers`, `env_vars`, and
|
||||
literal `env` tables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from migrate.common import (
|
||||
CODEX_CONFIG_PATH,
|
||||
MigrationReportItem,
|
||||
json_object,
|
||||
json_string,
|
||||
json_string_tuple,
|
||||
path_exists_with_exact_case,
|
||||
)
|
||||
from migrate.settings import CLAUDE_MCP_JSON_RELATIVE
|
||||
from utils.util import TomlValue
|
||||
|
||||
|
||||
ENV_VAR_RE = re.compile(r"\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}\Z")
|
||||
BEARER_ENV_VAR_RE = re.compile(
|
||||
r"\ABearer\s+\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}\Z"
|
||||
)
|
||||
|
||||
|
||||
def mcp_server_toml_table(
|
||||
server_name: str,
|
||||
server_config: Mapping[str, object],
|
||||
enabled_servers: tuple[str, ...],
|
||||
disabled_servers: frozenset[str],
|
||||
) -> dict[str, TomlValue]:
|
||||
table: dict[str, TomlValue] = {}
|
||||
enabled = mcp_enabled_state(server_config)
|
||||
if enabled is False:
|
||||
table["enabled"] = False
|
||||
elif enabled_servers and server_name not in enabled_servers:
|
||||
table["enabled"] = False
|
||||
elif server_name in disabled_servers:
|
||||
table["enabled"] = False
|
||||
if url := json_string(server_config.get("url")):
|
||||
table["url"] = url
|
||||
if command := json_string(server_config.get("command")):
|
||||
table["command"] = command
|
||||
if args := json_string_tuple(server_config.get("args")):
|
||||
table["args"] = list(args)
|
||||
if "headers" in server_config:
|
||||
append_header_config(table, json_object(server_config["headers"]))
|
||||
if "env" in server_config:
|
||||
append_env_config(table, json_object(server_config["env"]))
|
||||
return table
|
||||
|
||||
|
||||
def mcp_report_items(
|
||||
mcp_servers: tuple[tuple[str, dict[str, object]], ...],
|
||||
) -> list[MigrationReportItem]:
|
||||
report_items = [
|
||||
MigrationReportItem(
|
||||
"rewritten",
|
||||
CODEX_CONFIG_PATH,
|
||||
f"Converted {len(mcp_servers)} MCP server entries.",
|
||||
)
|
||||
]
|
||||
for server_name, server_config in mcp_servers:
|
||||
notes = mcp_manual_notes(server_name, server_config)
|
||||
if notes:
|
||||
report_items.append(
|
||||
MigrationReportItem(
|
||||
"manual_fix_required",
|
||||
CODEX_CONFIG_PATH,
|
||||
f"MCP server `{server_name}` needs review: {' '.join(notes)}",
|
||||
)
|
||||
)
|
||||
return report_items
|
||||
|
||||
|
||||
def append_header_config(
|
||||
table: dict[str, TomlValue],
|
||||
headers: Mapping[str, object],
|
||||
) -> None:
|
||||
static_headers: dict[str, str] = {}
|
||||
env_headers: dict[str, str] = {}
|
||||
|
||||
for key, value in headers.items():
|
||||
header_value = str(value)
|
||||
bearer_match = BEARER_ENV_VAR_RE.match(header_value)
|
||||
if key.lower() == "authorization" and bearer_match:
|
||||
table["bearer_token_env_var"] = bearer_match.group(1)
|
||||
continue
|
||||
|
||||
env_match = ENV_VAR_RE.match(header_value)
|
||||
if env_match:
|
||||
env_headers[key] = env_match.group(1)
|
||||
continue
|
||||
|
||||
static_headers[key] = header_value
|
||||
|
||||
if static_headers:
|
||||
table["http_headers"] = static_headers
|
||||
if env_headers:
|
||||
table["env_http_headers"] = env_headers
|
||||
|
||||
|
||||
def append_env_config(
|
||||
table: dict[str, TomlValue],
|
||||
env: Mapping[str, object],
|
||||
) -> None:
|
||||
static_env: dict[str, str] = {}
|
||||
env_vars: list[str] = []
|
||||
|
||||
for key, value in env.items():
|
||||
env_value = str(value)
|
||||
env_match = ENV_VAR_RE.match(env_value)
|
||||
if env_match and env_match.group(1) == key:
|
||||
env_vars.append(key)
|
||||
continue
|
||||
|
||||
static_env[key] = env_value
|
||||
|
||||
if env_vars:
|
||||
table["env_vars"] = env_vars
|
||||
if static_env:
|
||||
table["env"] = static_env
|
||||
|
||||
|
||||
def mcp_manual_notes(
|
||||
server_name: str,
|
||||
server_config: Mapping[str, object],
|
||||
) -> tuple[str, ...]:
|
||||
notes: list[str] = []
|
||||
source_type = json_string(server_config.get("type"))
|
||||
if source_type and source_type not in {"http", "stdio"}:
|
||||
notes.append(
|
||||
f"Claude MCP `type: {source_type}` was not written to Codex config; verify that the generated `url` or `command` is a supported Codex transport."
|
||||
)
|
||||
unsupported_fields = unsupported_mcp_server_fields(server_config)
|
||||
if unsupported_fields:
|
||||
notes.append(
|
||||
"Review unsupported Claude MCP fields manually: "
|
||||
+ ", ".join(f"`{field_name}`" for field_name in unsupported_fields)
|
||||
+ "."
|
||||
)
|
||||
return tuple(notes)
|
||||
|
||||
|
||||
def mcp_enabled_state(server_config: Mapping[str, object]) -> bool | None:
|
||||
if server_config.get("enabled") is False:
|
||||
return False
|
||||
if server_config.get("disabled") is True:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def unsupported_mcp_server_fields(
|
||||
server_config: Mapping[str, object],
|
||||
) -> tuple[str, ...]:
|
||||
supported = {
|
||||
"args",
|
||||
"command",
|
||||
"disabled",
|
||||
"enabled",
|
||||
"env",
|
||||
"headers",
|
||||
"name",
|
||||
"scope",
|
||||
"type",
|
||||
"url",
|
||||
}
|
||||
return tuple(sorted(key for key in server_config if key not in supported))
|
||||
|
||||
|
||||
def read_claude_mcp_servers(source_root: Path) -> tuple[tuple[str, dict[str, object]], ...]:
|
||||
servers: list[tuple[str, dict[str, object]]] = []
|
||||
for relative_path in CLAUDE_MCP_JSON_RELATIVE:
|
||||
source_file = source_root / relative_path
|
||||
if not path_exists_with_exact_case(source_file):
|
||||
continue
|
||||
mcp_config = json_object(json.loads(source_file.read_text()))
|
||||
for server_name, server_config in json_object(mcp_config.get("mcpServers")).items():
|
||||
servers.append((server_name, json_object(server_config)))
|
||||
return tuple(servers)
|
||||
|
||||
|
||||
def validate_mcp_commands(config: dict[str, object]) -> list[MigrationReportItem]:
|
||||
mcp_servers = config.get("mcp_servers")
|
||||
if not isinstance(mcp_servers, dict):
|
||||
return []
|
||||
|
||||
report_items: list[MigrationReportItem] = []
|
||||
for server_name, server_config in sorted(mcp_servers.items()):
|
||||
if not isinstance(server_config, dict):
|
||||
continue
|
||||
command = server_config.get("command")
|
||||
if not command:
|
||||
continue
|
||||
command_text = str(command)
|
||||
if shutil.which(command_text):
|
||||
report_items.append(
|
||||
MigrationReportItem(
|
||||
"ok",
|
||||
CODEX_CONFIG_PATH,
|
||||
f"MCP server `{server_name}` command `{command_text}` is on PATH.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
report_items.append(
|
||||
MigrationReportItem(
|
||||
"warning",
|
||||
CODEX_CONFIG_PATH,
|
||||
f"MCP server `{server_name}` command `{command_text}` was not found on PATH.",
|
||||
)
|
||||
)
|
||||
return report_items
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Report Claude Code plugin surfaces that need manual Codex migration.
|
||||
|
||||
Claude Code plugins and plugin marketplaces can bundle commands, agents, MCP
|
||||
servers, skills, and hooks with provider-specific metadata. The migrator reports
|
||||
their presence as manual follow-up; it does not install Codex plugins, copy
|
||||
plugin trees, or read marketplace `source` entries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from migrate.common import ConversionResult, ScopePaths, report_manual_paths
|
||||
from migrate.settings import CLAUDE_PLUGIN_MANUAL_PATHS
|
||||
|
||||
|
||||
def report_plugins(scope: ScopePaths) -> ConversionResult:
|
||||
return report_manual_paths(scope, CLAUDE_PLUGIN_MANUAL_PATHS)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Shared source path constants for migration discovery/reporting.
|
||||
|
||||
Constants here describe where Claude Code commonly stores instructions,
|
||||
commands, skills, agents, MCP config, plugin references, and hooks. Paths are
|
||||
relative to `ScopePaths.source`, the directory containing `.claude`, `.mcp.json`,
|
||||
and similar source roots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
CLAUDE_SETTINGS_JSON_RELATIVE = (
|
||||
Path(".claude") / "settings.json",
|
||||
Path(".claude") / "settings.local.json",
|
||||
)
|
||||
|
||||
CLAUDE_MCP_JSON_RELATIVE = (
|
||||
Path(".mcp.json"),
|
||||
Path(".claude.json"),
|
||||
)
|
||||
|
||||
CLAUDE_PLUGIN_MANUAL_PATHS = (
|
||||
(Path(".claude") / "plugins", "Claude Code plugins"),
|
||||
(
|
||||
Path(".claude") / "plugin-marketplaces.json",
|
||||
"Claude Code plugin marketplace registry",
|
||||
),
|
||||
(
|
||||
Path(".claude-plugin") / "marketplace.json",
|
||||
"Claude Code plugin marketplace",
|
||||
),
|
||||
)
|
||||
|
||||
SOURCE_SCAN_ROOTS = (
|
||||
(Path(".claude"), "primary source"),
|
||||
)
|
||||
|
||||
SOURCE_SCOPE_MARKERS = (
|
||||
Path(".claude"),
|
||||
)
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Convert Claude Code skills and commands into Codex skills.
|
||||
|
||||
Reads `.claude/skills/<name>/SKILL.md` and `.claude/skills/<name>.md`, then emits
|
||||
`.agents/skills/<name>/SKILL.md` plus supported helper directories for directory
|
||||
skills. Also wraps `.claude/commands/*.md` as
|
||||
one-file Codex skills. Runtime placeholders, file expansion, shell
|
||||
interpolation, and unsupported metadata are preserved with manual-review
|
||||
caveats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from migrate.common import (
|
||||
CODEX_SKILLS_ROOT,
|
||||
ArtifactKind,
|
||||
ConversionResult,
|
||||
GeneratedText,
|
||||
MigrationReportItem,
|
||||
ParsedDocument,
|
||||
PlannedArtifact,
|
||||
SimpleYamlFrontmatter,
|
||||
append_report_item,
|
||||
format_bullets,
|
||||
format_frontmatter,
|
||||
format_manual_migration_block,
|
||||
is_path_within_root,
|
||||
manual_report_item,
|
||||
parse_frontmatter,
|
||||
unsupported_frontmatter_fields,
|
||||
)
|
||||
from utils.util import slugify_name
|
||||
|
||||
|
||||
COMMAND_FILE_SOURCES = (
|
||||
(Path(".claude") / "commands", "source-command", "source command"),
|
||||
)
|
||||
|
||||
SKILL_SOURCE_ROOTS = (
|
||||
Path(".claude") / "skills",
|
||||
)
|
||||
SKILL_SUPPORT_DIRS = ("scripts", "references", "assets")
|
||||
|
||||
|
||||
def iter_skill_files(source_root: Path) -> tuple[Path, ...]:
|
||||
if not source_root.exists():
|
||||
return ()
|
||||
single_file_skills = tuple(
|
||||
source_file
|
||||
for source_file in sorted(source_root.glob("*.md"))
|
||||
if source_file.stem != "README"
|
||||
)
|
||||
directory_skills = tuple(sorted(source_root.glob("*/SKILL.md")))
|
||||
return single_file_skills + directory_skills
|
||||
|
||||
|
||||
def skill_target_name(source_file: Path) -> str:
|
||||
if source_file.name == "SKILL.md":
|
||||
return source_file.parent.name
|
||||
return source_file.stem
|
||||
|
||||
|
||||
def command_caveats(
|
||||
template: str,
|
||||
unsupported_fields: Sequence[str],
|
||||
) -> tuple[str, ...]:
|
||||
caveats: list[str] = []
|
||||
if re.search(r"\$(ARGUMENTS|\d+)\b", template):
|
||||
caveats.append(
|
||||
"Provider argument placeholders like `$ARGUMENTS` or `$1` were preserved as text; rewrite them into natural-language instructions for Codex."
|
||||
)
|
||||
if "{{" in template and "}}" in template:
|
||||
caveats.append(
|
||||
"Provider template variables like `{{name}}` were preserved as text; rewrite them into natural-language instructions for Codex."
|
||||
)
|
||||
if re.search(r"!\s*`", template):
|
||||
caveats.append(
|
||||
"Provider shell-output interpolation like ``!`command` `` was preserved as text; replace it with explicit Codex instructions to run the command when needed."
|
||||
)
|
||||
if re.search(r"(^|\s)@[\w./~:-]+", template):
|
||||
caveats.append(
|
||||
"Provider automatic file-reference expansion was preserved as text; verify Codex should read those files explicitly."
|
||||
)
|
||||
if unsupported_fields:
|
||||
caveats.append(
|
||||
"Review unsupported command metadata manually: "
|
||||
+ ", ".join(f"`{field_name}`" for field_name in unsupported_fields)
|
||||
+ "."
|
||||
)
|
||||
return tuple(caveats)
|
||||
|
||||
|
||||
def convert_skills(source_root: Path) -> ConversionResult:
|
||||
result = convert_skill_files(source_root / ".claude" / "skills")
|
||||
result.add(convert_command_skills(source_root))
|
||||
return result
|
||||
|
||||
|
||||
def convert_skill_files(source_root: Path) -> ConversionResult:
|
||||
result = ConversionResult()
|
||||
for source_file in iter_skill_files(source_root):
|
||||
artifacts, report_item = convert_skill_file(source_file)
|
||||
result.artifacts.extend(artifacts)
|
||||
result.summary.skills += 1
|
||||
result.report_items.append(report_item)
|
||||
return result
|
||||
|
||||
|
||||
def convert_command_skills(source_root: Path) -> ConversionResult:
|
||||
result = ConversionResult()
|
||||
for command_source_root, name_prefix, provider in COMMAND_FILE_SOURCES:
|
||||
result.add(
|
||||
convert_markdown_command_files(
|
||||
source_root / command_source_root,
|
||||
name_prefix,
|
||||
provider,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def codex_skill_frontmatter(name: str, description: str) -> SimpleYamlFrontmatter:
|
||||
return SimpleYamlFrontmatter(
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def convert_skill_file(source_file: Path) -> tuple[list[PlannedArtifact], MigrationReportItem]:
|
||||
document = ParsedDocument.from_file(source_file)
|
||||
name = document.frontmatter.required_string("name")
|
||||
description = document.frontmatter.required_string("description")
|
||||
allowed_tools = document.frontmatter.string_tuple("allowed-tools")
|
||||
unsupported_fields = unsupported_frontmatter_fields(
|
||||
document.frontmatter.to_dict(),
|
||||
("name", "description", "allowed-tools"),
|
||||
)
|
||||
artifacts = [
|
||||
PlannedArtifact(
|
||||
relative_path=CODEX_SKILLS_ROOT / skill_target_name(source_file) / "SKILL.md",
|
||||
payload=GeneratedText(
|
||||
render_skill(
|
||||
document.body,
|
||||
name=name,
|
||||
description=description,
|
||||
allowed_tools=allowed_tools,
|
||||
unsupported_fields=unsupported_fields,
|
||||
)
|
||||
),
|
||||
kind=ArtifactKind.SKILL,
|
||||
)
|
||||
]
|
||||
artifacts.extend(skill_support_artifacts(source_file))
|
||||
return artifacts, skill_report_item(source_file, allowed_tools, unsupported_fields)
|
||||
|
||||
|
||||
def skill_support_artifacts(source_file: Path) -> list[PlannedArtifact]:
|
||||
if source_file.name != "SKILL.md":
|
||||
return []
|
||||
|
||||
artifacts: list[PlannedArtifact] = []
|
||||
skill_root = source_file.parent
|
||||
target_root = CODEX_SKILLS_ROOT / skill_root.name
|
||||
source_files: list[Path] = []
|
||||
for dirname in SKILL_SUPPORT_DIRS:
|
||||
source_dir = skill_root / dirname
|
||||
if not source_dir.exists():
|
||||
continue
|
||||
source_files.extend(
|
||||
source_file
|
||||
for source_file in source_dir.rglob("*")
|
||||
if source_file.is_file() and is_path_within_root(source_file, skill_root)
|
||||
)
|
||||
for support_file in sorted(
|
||||
source_files,
|
||||
key=lambda path: path.relative_to(skill_root).as_posix(),
|
||||
):
|
||||
artifacts.append(
|
||||
PlannedArtifact.from_source_file(
|
||||
support_file,
|
||||
target_root / support_file.relative_to(skill_root),
|
||||
)
|
||||
)
|
||||
return artifacts
|
||||
|
||||
|
||||
def render_skill(
|
||||
body: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
allowed_tools: tuple[str, ...],
|
||||
unsupported_fields: tuple[str, ...],
|
||||
) -> str:
|
||||
return format_frontmatter(
|
||||
codex_skill_frontmatter(name, description),
|
||||
render_skill_body(body, allowed_tools, unsupported_fields),
|
||||
)
|
||||
|
||||
|
||||
def render_skill_body(
|
||||
body: str,
|
||||
allowed_tools: tuple[str, ...],
|
||||
unsupported_fields: tuple[str, ...],
|
||||
) -> str:
|
||||
manual_notes: list[str] = []
|
||||
if allowed_tools:
|
||||
manual_notes.append(
|
||||
"Claude `allowed-tools` was preserved as prompt guidance, not a Codex permission boundary.\n\n"
|
||||
"You're allowed to use these tools:\n\n"
|
||||
f"{format_bullets(allowed_tools)}"
|
||||
)
|
||||
if unsupported_fields:
|
||||
manual_notes.append(
|
||||
"Review unsupported Claude skill fields manually: "
|
||||
f"{', '.join(f'`{field_name}`' for field_name in unsupported_fields)}."
|
||||
)
|
||||
|
||||
if not manual_notes:
|
||||
return body
|
||||
|
||||
return f"{body.rstrip()}\n\n{format_manual_migration_block(manual_notes)}\n"
|
||||
|
||||
|
||||
def skill_report_detail(
|
||||
allowed_tools: tuple[str, ...],
|
||||
unsupported_fields: tuple[str, ...],
|
||||
) -> str:
|
||||
caveats: list[str] = []
|
||||
if allowed_tools:
|
||||
caveats.append("allowed-tools")
|
||||
caveats.extend(unsupported_fields)
|
||||
if not caveats:
|
||||
return "Converted Claude skill."
|
||||
return (
|
||||
"Manual review required for Claude skill fields: "
|
||||
+ ", ".join(f"`{field_name}`" for field_name in caveats)
|
||||
+ "."
|
||||
)
|
||||
|
||||
|
||||
def skill_report_item(
|
||||
source_file: Path,
|
||||
allowed_tools: tuple[str, ...],
|
||||
unsupported_fields: tuple[str, ...],
|
||||
) -> MigrationReportItem:
|
||||
report_items: list[MigrationReportItem] = []
|
||||
detail = skill_report_detail(allowed_tools, unsupported_fields)
|
||||
append_report_item(
|
||||
report_items,
|
||||
allowed_tools or unsupported_fields,
|
||||
CODEX_SKILLS_ROOT / skill_target_name(source_file) / "SKILL.md",
|
||||
detail,
|
||||
detail,
|
||||
)
|
||||
return report_items[0]
|
||||
|
||||
|
||||
def convert_markdown_command_files(
|
||||
source_root: Path,
|
||||
name_prefix: str,
|
||||
provider: str,
|
||||
) -> ConversionResult:
|
||||
result = ConversionResult()
|
||||
if not source_root.exists():
|
||||
return result
|
||||
for source_file in sorted(source_root.rglob("*.md")):
|
||||
artifact, report_item = convert_command_file(
|
||||
source_root,
|
||||
source_file,
|
||||
name_prefix,
|
||||
provider,
|
||||
)
|
||||
result.artifacts.append(artifact)
|
||||
result.summary.skills += 1
|
||||
result.report_items.append(report_item)
|
||||
return result
|
||||
|
||||
|
||||
def convert_command_file(
|
||||
source_root: Path,
|
||||
source_file: Path,
|
||||
name_prefix: str,
|
||||
provider: str,
|
||||
) -> tuple[PlannedArtifact, MigrationReportItem]:
|
||||
document = ParsedDocument.from_file(source_file)
|
||||
source_name = "-".join(source_file.relative_to(source_root).with_suffix("").parts)
|
||||
name = slugify_name(f"{name_prefix}-{source_name}")
|
||||
description = document.frontmatter.optional_string("description")
|
||||
if not description:
|
||||
description = f"Run the migrated {provider} `{source_name}`."
|
||||
unsupported_fields = unsupported_frontmatter_fields(
|
||||
document.frontmatter.to_dict(),
|
||||
("description",),
|
||||
)
|
||||
caveats = command_caveats(document.body, unsupported_fields)
|
||||
artifact = PlannedArtifact(
|
||||
relative_path=CODEX_SKILLS_ROOT / name / "SKILL.md",
|
||||
payload=GeneratedText(
|
||||
render_command_skill(
|
||||
document.body,
|
||||
name=name,
|
||||
description=description,
|
||||
provider=provider,
|
||||
source_name=source_name,
|
||||
caveats=caveats,
|
||||
)
|
||||
),
|
||||
kind=ArtifactKind.SKILL,
|
||||
)
|
||||
return artifact, command_report_item(name, provider, source_name)
|
||||
|
||||
|
||||
def render_command_skill(
|
||||
body: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
provider: str,
|
||||
source_name: str,
|
||||
caveats: tuple[str, ...],
|
||||
) -> str:
|
||||
manual_notes = [
|
||||
f"Migrated from {provider} `{source_name}` into a Codex skill. "
|
||||
f"Invoke it as `${name}` and manually rewrite any slash-command behavior that depended on provider-specific runtime expansion."
|
||||
]
|
||||
manual_notes.extend(caveats)
|
||||
template_body = body.strip() or "No command template body was found."
|
||||
return format_frontmatter(
|
||||
codex_skill_frontmatter(name, description),
|
||||
f"# {name}\n\n"
|
||||
"Use this skill when the user asks to run the migrated "
|
||||
f"{provider} `{source_name}`.\n\n"
|
||||
"## Command Template\n\n"
|
||||
f"{template_body}\n\n"
|
||||
f"{format_manual_migration_block(manual_notes)}\n",
|
||||
)
|
||||
|
||||
|
||||
def validate_skill_files(target_root: Path) -> list[MigrationReportItem]:
|
||||
skills_root = target_root / CODEX_SKILLS_ROOT
|
||||
if not skills_root.exists():
|
||||
return []
|
||||
|
||||
report_items: list[MigrationReportItem] = []
|
||||
for skill_file in sorted(skills_root.glob("*/SKILL.md")):
|
||||
relative_path = skill_file.relative_to(target_root)
|
||||
document = parse_frontmatter(skill_file.read_text(), skill_file)
|
||||
missing = [
|
||||
key
|
||||
for key in ("name", "description")
|
||||
if not document.frontmatter.optional_string(key)
|
||||
]
|
||||
if missing:
|
||||
report_items.append(
|
||||
MigrationReportItem(
|
||||
"error",
|
||||
relative_path,
|
||||
"skill frontmatter missing " + ", ".join(missing) + ".",
|
||||
)
|
||||
)
|
||||
continue
|
||||
report_items.append(
|
||||
MigrationReportItem(
|
||||
"ok",
|
||||
relative_path,
|
||||
"skill frontmatter has name and description.",
|
||||
)
|
||||
)
|
||||
return report_items
|
||||
|
||||
|
||||
def command_report_detail(provider: str, source_name: str) -> str:
|
||||
return (
|
||||
f"Converted {provider} `{source_name}` to a single-file Codex skill; "
|
||||
"review invocation and template placeholder semantics."
|
||||
)
|
||||
|
||||
|
||||
def command_report_item(
|
||||
name: str,
|
||||
provider: str,
|
||||
source_name: str,
|
||||
) -> MigrationReportItem:
|
||||
return manual_report_item(
|
||||
CODEX_SKILLS_ROOT / name / "SKILL.md",
|
||||
command_report_detail(provider, source_name),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
# Migration script helper modules.
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def should_skip_inventory_child(child: Path) -> bool:
|
||||
return child.name in {".DS_Store", "__pycache__"}
|
||||
|
||||
|
||||
def command_file_inventory(
|
||||
source_root: Path,
|
||||
command_file_sources: Sequence[tuple[Path, str, str]],
|
||||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
||||
inventory: list[tuple[str, tuple[str, ...]]] = []
|
||||
for relative_root, _name_prefix, provider in command_file_sources:
|
||||
absolute_root = source_root / relative_root
|
||||
if not absolute_root.exists():
|
||||
continue
|
||||
command_names = tuple(
|
||||
sorted(
|
||||
source_file.relative_to(absolute_root).with_suffix("").as_posix()
|
||||
for source_file in absolute_root.rglob("*.md")
|
||||
)
|
||||
)
|
||||
if command_names:
|
||||
inventory.append((provider, command_names))
|
||||
return tuple(inventory)
|
||||
|
||||
|
||||
def render_named_inventory(
|
||||
lines: list[str],
|
||||
label: str,
|
||||
values: Sequence[str],
|
||||
) -> None:
|
||||
if not values:
|
||||
lines.append(f" inactive: {label} - none found")
|
||||
return
|
||||
lines.append(f" active: {label} - {len(values)} found")
|
||||
for value in values:
|
||||
lines.append(f" - {value}")
|
||||
|
||||
|
||||
def render_scope_inventory(
|
||||
source_root: Path,
|
||||
instruction_source_candidates: Sequence[Path],
|
||||
command_file_sources: Sequence[tuple[Path, str, str]],
|
||||
skill_source_roots: Sequence[Path],
|
||||
agent_source_roots: Sequence[Path],
|
||||
iter_skill_files: Callable[[Path], Sequence[Path]],
|
||||
iter_agent_files: Callable[[Path], Sequence[Path]],
|
||||
path_exists_with_exact_case: Callable[[Path], bool],
|
||||
) -> str:
|
||||
lines = ["", "Migration inventory:"]
|
||||
instruction_candidates = tuple(
|
||||
candidate.as_posix()
|
||||
for candidate in instruction_source_candidates
|
||||
if path_exists_with_exact_case(source_root / candidate)
|
||||
)
|
||||
skill_names = tuple(
|
||||
sorted(
|
||||
{
|
||||
source_file.parent.name
|
||||
for relative_root in skill_source_roots
|
||||
for source_file in iter_skill_files(source_root / relative_root)
|
||||
}
|
||||
)
|
||||
)
|
||||
agent_names = tuple(
|
||||
sorted(
|
||||
{
|
||||
source_file.stem
|
||||
for relative_root in agent_source_roots
|
||||
for source_file in iter_agent_files(source_root / relative_root)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
render_named_inventory(lines, "instruction files", instruction_candidates)
|
||||
render_named_inventory(lines, "skills", skill_names)
|
||||
|
||||
command_inventory = command_file_inventory(source_root, command_file_sources)
|
||||
if not command_inventory:
|
||||
lines.append(" inactive: command sources - none found")
|
||||
else:
|
||||
total_commands = sum(
|
||||
len(command_names) for _, command_names in command_inventory
|
||||
)
|
||||
lines.append(f" active: command sources - {total_commands} found")
|
||||
for provider, command_names in command_inventory:
|
||||
lines.append(f" provider: {provider} ({len(command_names)})")
|
||||
for command_name in command_names:
|
||||
lines.append(f" - {command_name}")
|
||||
|
||||
render_named_inventory(lines, "subagents", agent_names)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_source_inventory(
|
||||
source_root: Path,
|
||||
source_scan_roots: Sequence[tuple[Path, str]],
|
||||
path_exists_with_exact_case: Callable[[Path], bool],
|
||||
) -> str:
|
||||
lines = ["", "Source inventory:"]
|
||||
discovered = False
|
||||
|
||||
for relative_root, label in source_scan_roots:
|
||||
absolute_root = source_root / relative_root
|
||||
if not path_exists_with_exact_case(absolute_root):
|
||||
continue
|
||||
discovered = True
|
||||
lines.append(f" detected: {relative_root.as_posix()} - {label}")
|
||||
try:
|
||||
children = sorted(
|
||||
absolute_root.iterdir(), key=lambda child: child.name.lower()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
for child in children:
|
||||
if should_skip_inventory_child(child):
|
||||
continue
|
||||
child_kind = "dir" if child.is_dir() else "file"
|
||||
lines.append(f" {child_kind}: {(relative_root / child.name).as_posix()}")
|
||||
|
||||
if not discovered:
|
||||
lines.append(" inactive: No supported source directories found.")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypeAlias
|
||||
|
||||
|
||||
YamlScalar: TypeAlias = str | bool | int | float | None
|
||||
YamlValue: TypeAlias = YamlScalar | Sequence[YamlScalar]
|
||||
TomlScalar: TypeAlias = str | bool | int | float | None
|
||||
TomlValue: TypeAlias = object
|
||||
|
||||
|
||||
def detected_json_keys(content: str, keys: Sequence[str]) -> tuple[str, ...]:
|
||||
return tuple(key for key in keys if re.search(rf'"{re.escape(key)}"\s*:', content))
|
||||
|
||||
|
||||
def strip_jsonc_comments(content: str) -> str:
|
||||
lines: list[str] = []
|
||||
for line in content.splitlines():
|
||||
in_string = False
|
||||
escaped = False
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
while index < len(line):
|
||||
char = line[index]
|
||||
if escaped:
|
||||
result.append(char)
|
||||
escaped = False
|
||||
index += 1
|
||||
continue
|
||||
if char == "\\" and in_string:
|
||||
result.append(char)
|
||||
escaped = True
|
||||
index += 1
|
||||
continue
|
||||
if char == '"':
|
||||
in_string = not in_string
|
||||
result.append(char)
|
||||
index += 1
|
||||
continue
|
||||
if (
|
||||
not in_string
|
||||
and char == "/"
|
||||
and index + 1 < len(line)
|
||||
and line[index + 1] == "/"
|
||||
):
|
||||
break
|
||||
result.append(char)
|
||||
index += 1
|
||||
lines.append("".join(result))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def load_jsonc_object(content: str, json_object: callable) -> Mapping[str, object]:
|
||||
without_comments = strip_jsonc_comments(content)
|
||||
without_trailing_commas = re.sub(r",\s*([}\]])", r"\1", without_comments)
|
||||
return json_object(json.loads(without_trailing_commas))
|
||||
|
||||
|
||||
def parse_jsonc_mapping_text(text: str) -> Mapping[str, object] | None:
|
||||
"""Return the top-level JSON object, or None if the text is not a JSON object."""
|
||||
try:
|
||||
without_comments = strip_jsonc_comments(text)
|
||||
without_trailing_commas = re.sub(r",\s*([}\]])", r"\1", without_comments)
|
||||
parsed = json.loads(without_trailing_commas)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
if isinstance(parsed, Mapping):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JsonMappingFileRead:
|
||||
exists: bool
|
||||
ok: bool
|
||||
data: Mapping[str, object]
|
||||
|
||||
|
||||
def read_json_mapping_file(path: Path) -> JsonMappingFileRead:
|
||||
"""Read a JSON/JSONC file. ``ok`` is False when the file exists but could not be parsed."""
|
||||
if not path.is_file():
|
||||
return JsonMappingFileRead(exists=False, ok=True, data={})
|
||||
text = path.read_text()
|
||||
parsed = parse_jsonc_mapping_text(text)
|
||||
if parsed is None:
|
||||
return JsonMappingFileRead(exists=True, ok=False, data={})
|
||||
return JsonMappingFileRead(exists=True, ok=True, data=parsed)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TomlMultilineString:
|
||||
value: str
|
||||
|
||||
|
||||
def parse_yaml_mapping(content: str) -> dict[str, YamlValue]:
|
||||
"""Parse the small YAML-frontmatter subset used by Claude metadata."""
|
||||
result: dict[str, YamlValue] = {}
|
||||
current_key: str | None = None
|
||||
|
||||
for raw_line in content.splitlines():
|
||||
if not raw_line.strip():
|
||||
continue
|
||||
|
||||
if raw_line.startswith(" - ") and current_key:
|
||||
current_value = result.setdefault(current_key, [])
|
||||
if not isinstance(current_value, list):
|
||||
current_value = [current_value]
|
||||
result[current_key] = current_value
|
||||
current_value.append(parse_yaml_value(raw_line[4:].strip()))
|
||||
continue
|
||||
|
||||
key, separator, value = raw_line.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
|
||||
current_key = key.strip()
|
||||
value = value.strip()
|
||||
result[current_key] = parse_yaml_value(value) if value else []
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_yaml_value(value: str) -> YamlValue:
|
||||
if value in {"true", "True"}:
|
||||
return True
|
||||
if value in {"false", "False"}:
|
||||
return False
|
||||
if value in {"null", "Null", "~"}:
|
||||
return None
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
return tuple(
|
||||
parse_yaml_value(item)
|
||||
for item in split_delimited_values(value[1:-1])
|
||||
if item
|
||||
)
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value[1:-1]
|
||||
if value.startswith("'") and value.endswith("'"):
|
||||
return value[1:-1].replace("''", "'")
|
||||
return value
|
||||
|
||||
|
||||
def split_delimited_values(content: str) -> tuple[str, ...]:
|
||||
values: list[str] = []
|
||||
token: list[str] = []
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
for char in content:
|
||||
if escaped:
|
||||
token.append(char)
|
||||
escaped = False
|
||||
continue
|
||||
if char == "\\" and quote == '"':
|
||||
token.append(char)
|
||||
escaped = True
|
||||
continue
|
||||
if quote:
|
||||
token.append(char)
|
||||
if char == quote:
|
||||
quote = None
|
||||
continue
|
||||
if char in {"'", '"'}:
|
||||
token.append(char)
|
||||
quote = char
|
||||
continue
|
||||
if char == ",":
|
||||
values.append("".join(token).strip())
|
||||
token = []
|
||||
continue
|
||||
token.append(char)
|
||||
values.append("".join(token).strip())
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def format_yaml_mapping(values: Mapping[str, YamlValue]) -> str:
|
||||
return "\n".join(
|
||||
f"{key}: {format_yaml_value(value)}" for key, value in values.items()
|
||||
)
|
||||
|
||||
|
||||
def format_yaml_value(value: YamlValue) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return "[" + ", ".join(format_yaml_value(item) for item in value) + "]"
|
||||
return json.dumps(str(value))
|
||||
|
||||
|
||||
def render_toml_document(values: Mapping[str, TomlValue]) -> str:
|
||||
lines: list[str] = []
|
||||
append_toml_entries(lines, values)
|
||||
for key, value in values.items():
|
||||
if isinstance(value, Mapping):
|
||||
append_toml_table(lines, (key,), value)
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def append_toml_table(
|
||||
lines: list[str],
|
||||
path: tuple[str, ...],
|
||||
values: Mapping[str, TomlValue],
|
||||
) -> None:
|
||||
append_blank_line(lines)
|
||||
lines.append("[" + ".".join(format_toml_key(path_part) for path_part in path) + "]")
|
||||
append_toml_entries(lines, values)
|
||||
|
||||
for key, value in values.items():
|
||||
if isinstance(value, Mapping):
|
||||
append_toml_table(lines, (*path, key), value)
|
||||
|
||||
|
||||
def append_toml_entries(lines: list[str], values: Mapping[str, TomlValue]) -> None:
|
||||
for key, value in values.items():
|
||||
if isinstance(value, Mapping):
|
||||
continue
|
||||
lines.append(f"{format_toml_key(key)} = {format_toml_value(value)}")
|
||||
|
||||
|
||||
def append_blank_line(lines: list[str]) -> None:
|
||||
if lines and lines[-1]:
|
||||
lines.append("")
|
||||
|
||||
|
||||
def format_toml_key(key: str) -> str:
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]+", key):
|
||||
return key
|
||||
return json.dumps(key)
|
||||
|
||||
|
||||
def format_toml_value(value: TomlValue) -> str:
|
||||
if isinstance(value, TomlMultilineString):
|
||||
return format_toml_multiline_string(value.value)
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return '""'
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return "[" + ", ".join(format_toml_value(item) for item in value) + "]"
|
||||
return json.dumps(str(value))
|
||||
|
||||
|
||||
def format_toml_multiline_string(value: str) -> str:
|
||||
escaped = value.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
|
||||
return f'"""{escaped}"""'
|
||||
|
||||
|
||||
def slugify_name(value: str) -> str:
|
||||
result = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip()).strip("-").lower()
|
||||
return result or "migrated-command"
|
||||
|
||||
|
||||
def first_markdown_heading(content: str) -> str | None:
|
||||
for line in content.splitlines():
|
||||
match = re.match(r"^#\s+(.+?)\s*$", line)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def format_backtick_list(values: Sequence[str]) -> str:
|
||||
if not values:
|
||||
return ""
|
||||
if len(values) == 1:
|
||||
return f"`{values[0]}`"
|
||||
return ", ".join(f"`{value}`" for value in values[:-1]) + f", and `{values[-1]}`"
|
||||
|
||||
|
||||
def normalize_source_scope_root(
|
||||
path: Path, source_scope_markers: Sequence[Path]
|
||||
) -> Path:
|
||||
resolved = path
|
||||
for marker in source_scope_markers:
|
||||
if resolved.parts[-len(marker.parts) :] == marker.parts:
|
||||
return resolved.parents[len(marker.parts) - 1]
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_source_root(source: str) -> Path:
|
||||
if not glob.has_magic(source):
|
||||
return Path(source)
|
||||
|
||||
matches = [Path(match) for match in glob.glob(source, recursive=True)]
|
||||
if not matches:
|
||||
raise FileNotFoundError(f"No matches for source pattern: {source}")
|
||||
|
||||
for match in matches:
|
||||
if (
|
||||
match.is_dir()
|
||||
and (match / "global").exists()
|
||||
and (match / "project").exists()
|
||||
):
|
||||
return match
|
||||
|
||||
static_prefix = source.split("*", 1)[0].rstrip("/")
|
||||
return Path(static_prefix)
|
||||
Reference in New Issue
Block a user