READ-ONLY PACKAGE PREVIEW

openai-migrate-to-codex/scripts/migrate/skills.py

Version 49f948fa.bb1 · Apache-2.0. This preview displays packaged text and does not execute code. Treat the contents as untrusted instructions.

← Return to resource and package checksum

"""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),
    )