Skip to content

Claude Code skill import rewrites .claude/ paths to .Codex/, producing directories that don't exist #40147

Open

Description

@chowdhuryaj

Summary

The external-agent import that copies Claude Code skills into ~/.agents/skills/
substitutes the token claude → Codex across the entire file body, including
inside literal filesystem paths. Prose retargeting is correct and useful; path
rewriting is not, because it produces paths that do not exist.

Repro

  1. Have a Claude Code skill at ~/.claude/skills/example/SKILL.md whose body
    references a real path, e.g.:

    Session transcripts live in ~/.claude/projects/<slug>/
    See also .claude/commands/example.md
    
  2. Let the Codex desktop onboarding import run ([desktop] external-agent-import-sync-enabled = true).

  3. Read the imported copy at ~/.agents/skills/example/SKILL.md.

Actual

Session transcripts live in ~/.Codex/projects/<slug>/
See also .Codex/commands/example.md

Expected

The path should be left as ~/.claude/projects/<slug>/. It is an absolute
reference to a real directory owned by another tool, not an agent-relative path
that has a Codex equivalent.

Why this breaks

Two separate problems:

  1. Wrong case. Codex is the display name; the directory is .codex. On
    case-insensitive APFS this happens to resolve, so it is silent on macOS and
    would break on a case-sensitive volume or on Linux.

  2. No such directory — the real problem. Codex has no analogue for most of
    these. On my machine, after import:

    rewritten to exists?
    ~/.codex/commands/ no
    ~/.codex/projects/ no
    ~/.codex/handoffs/ no
    ~/.codex/plans/ no
    ~/.codex/skills/ yes (coincidence — same directory name)

    So every imported skill that cites a ~/.claude/<subdir>/ path now sends the
    agent to a directory that does not exist. In my case that was 10 references
    across 5 imported skills.

The prose substitutions in the same pass are correct and worth keeping —
CLAUDE.md → AGENTS.md, "Claude Code UI" → "Codex UI". It is specifically the
path-shaped occurrences that should be exempt.

Suggested fix

Skip substitution for occurrences that look like filesystem paths — a match
preceded by /, ~/, or $HOME/, or immediately followed by /. A skill that
names another tool's data directory means that directory literally.

Failing that, at minimum substitute .claude → .codex rather than .Codex,
so the result is not also case-wrong.

Environment

  • Codex CLI v0.149.0-alpha.4.1 (ChatGPT desktop, macOS)
  • macOS 15 / Darwin 25.5.0, APFS (case-insensitive)
  • Relevant module per binary strings: codex_tui::external_agent_config_migration

Workaround

Set external-agent-import-sync-enabled = false under [desktop] in
~/.codex/config.toml after the initial import, and repair the imported paths by
hand. The import is one-shot, so repairs survive normal codex exec use.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    skillsIssues related to skills
    on Aug 22, 2026
  2. chowdhuryaj commented on Aug 22, 2026

    @chowdhuryaj
    Author

    Follow-up: the same "rewrite assumes the target exists" flaw applies to the
    CLAUDE.md → AGENTS.md substitution, which I'd called correct in the original
    report. It is correct only when the referenced repo actually has an AGENTS.md.

    Concrete case from the same import. Two skills each pointed at a project's root
    instruction file:

    skill references repo has CLAUDE.md repo has AGENTS.md rewrite result
    project A's root instruction file yes yes fine
    project B's root instruction file yes no now points at a missing file

    For project B the imported skill says "read AGENTS.md in the repo root — it is
    authoritative", and no such file exists. The authoritative file is still
    CLAUDE.md, sitting right there unread.

    This matters more than the path case, because these rewrites land on content
    instructions rather than incidental references. A skill whose first step is "read
    the authoritative file" silently becomes a skill whose first step fails.

    Suggested refinement to the fix: make the substitution conditional on the target
    resolving. At import time, for a reference to <dir>/CLAUDE.md, only rewrite to
    AGENTS.md if <dir>/AGENTS.md exists; otherwise leave the original. That is a
    cheap stat per candidate and it degrades safely — worst case you keep a name
    that is merely off-brand, instead of emitting one that is wrong.

    The same conditional would fix the path case in the original report: .claude/x
    should only become .codex/x if ~/.codex/x exists.

  3. matebenyovszky commented on Aug 22, 2026

    @matebenyovszky

    Hit this on mixed Claude Code + Codex machines too. Blind claude → Codex inside a skill body is the wrong layer: ~/.claude/projects/ is a real directory, and CLAUDE.md → AGENTS.md is only safe when that file exists.

    Until the importer skips path-shaped matches (and stats the rewrite target), I keep one playbook and write each tool’s native layout, and leave the skill body alone:

    npx @agentplaybooks/cli@beta doctor .
    npx @agentplaybooks/cli@beta sync . --target=codex --apply

    That writes .codex/skills and .codex/config.toml without substituting claude inside SKILL.md. Same-named files with different content get reported and skipped.

    Still want the importer to refuse invented .Codex/ paths — a writer is a stopgap, not a fix.

  4. charle-z commented on Aug 22, 2026

    @charle-z

    Final exact-head implementation/validation update

    Root cause remains in the shared external-agent rewriter: replacements are selected with only ASCII word boundaries, so /, \\, and . let source-tool names inside filesystem paths, URLs, and dotted identifiers be rewritten as though they were prose.

    Final source branch: charle-z:fix/40147-preserve-external-agent-references-v4
    Final head: cc3b3697867dbd3854c84c90982bca05f836afe6
    Exact upstream base: c9b19deb09c1841ce7acc33ddb96276030936a29
    Branch: https://github.com/charle-z/codex/tree/fix/40147-preserve-external-agent-references-v4

    The final branch is exactly one commit ahead / zero behind that upstream base and modifies only:

    • codex-rs/external-agent-migration/src/rewrite.rs
    • codex-rs/external-agent-migration/src/rewrite_tests.rs

    The patch keeps the existing prose rewrite behavior but skips matches that are syntactically part of literal source references: immediately adjacent to / or \\, preceded by ., or followed by a dotted identifier/hostname suffix. Regression coverage includes POSIX/Windows paths, dot-directories, URLs/dotted identifiers, path-qualified documentation names, and the concrete Claude paths ~/.claude/projects, .claude/commands, and C:\\Users\\...\\.claude\\plans, while standalone Claude Code prose still becomes Codex.

    Exact-current-main focused validation is fully green:

    • exact source-shape guard: passed (head cc3b369..., 1 ahead / 0 behind / exactly 2 files)
    • just test -p codex-external-agent-migration: passed
    • just fix -p codex-external-agent-migration: passed
    • repository Rust formatting check: passed
    • final clean-tree (git diff --exit-code) check: passed

    Validation run: https://github.com/charle-z/codex/actions/runs/32652611137

    The validation base made the unrelated blocking-ci and v8-canary workflows manual-only, so this run was isolated to the issue-scoped job. The internal validation PR was closed without merge after the run.

    Scope note: this intentionally does not claim to solve the separate follow-up where a bare, unqualified CLAUDE.md token may refer to a real file but no AGENTS.md exists. RewriteProfile::rewrite() receives content only and is shared across skills/subagents/hooks, so there is no trustworthy filesystem base for a stat() decision. Guessing one would create another incorrect-rewrite class. The branch fixes the deterministic literal path/URL/dotted-reference corruption reported here.

    I attempted again to open an upstream draft PR from this exact-current-main branch, but GitHub returned 403 Resource not accessible by integration, consistent with this repository's collaborator-only cross-fork PR restriction. The branch/SHA/run above are the submit-ready artifact and evidence for maintainer comparison or cherry-pick.

  5. charle-z commented on Aug 26, 2026

    @charle-z

    Correction to my previous v4 validation: adversarial URL cases found one remaining rewrite gap. v4 preserved path/host/dotted forms, but could still retarget source-tool names in URL query/fragment positions and URI schemes, e.g. ...?agent=claude#claude, ...?q=Claude, and claude://session?id=claude.

    Updated branch: charle-z:fix/40147-preserve-external-agent-references-v5
    Head: 46d20eff12cfc8611194e47fa19a8e696192a976
    Upstream base: 10d5a603aecbd73a38f3a6576cce69a78f8d6f1d

    v5 keeps the fix syntactic: it recognizes source names inside :// URI tokens in addition to the existing path/dotted-reference checks. It does not stat the filesystem or add target-existence heuristics; standalone CLAUDE.md -> AGENTS.md behavior is intentionally unchanged.

    Validation: Rust 1.95.0, cargo-nextest 0.9.143; focal URL regression 1/1; codex-external-agent-migration 175/175; Clippy with -D warnings; just fix; just fmt; git diff --check. Diff remains limited to rewrite.rs and rewrite_tests.rs. Upstream advanced during validation but did not touch either file; final rebase was clean.

    I did not retry the previously blocked cross-fork PR.

  6. added 2 commits that reference this issue on Aug 26, 2026
  7. charle-z commented on Sep 6, 2026

    @charle-z

    5e4c996063 carries the v5 fix onto a947db131. It preserves literal paths, URLs including queries/fragments, URI schemes, Markdown destinations and dotted identifiers. Ordinary prose still gets retargeted.

    The change stays in rewrite.rs and rewrite_tests.rs. All 182 tests passed with just test -p codex-external-agent-migration on Linux/WSL, Rust 1.95.0. Scoped just fix, just fmt and the clean-tree check passed too.

    The separate CLAUDE.md concern above remains: standalone names are still rewritten, and this patch does not check whether the destination AGENTS.md exists.

  8. josh87523 commented on Sep 9, 2026

    @josh87523

    Additional reproducible cases and a source candidate tested against rust-v0.153.4.

    This overlaps the existing path-preservation fix discussed above, but also covers bare executable names and model arguments (claude --model claude-sonnet-4), unqualified source references (CLAUDE.md), and source/provider names. A filesystem existence check cannot establish that an executable/model/provider has the same meaning after replacement.

    The attached alternative candidate preserves free-form source text at the existing shared rewrite functions in both crates. It covers instruction files, agent descriptions/bodies, hook status messages and command templates; skills are copied byte-for-byte. Target filenames, permission/effort mappings, hook event/command-path conversion, existing-target guards and command wrappers remain unchanged. This is a broader preservation policy than the selective syntax patch already proposed: it also retains ordinary source-host prose, which may still need compatibility review. It does not claim to automatically adapt source-host instructions.

    Validation: final complete cargo test --locked -p codex-external-agent-migration -p codex-core-plugins --lib passed 439 + 169 = 608 tests, zero ignored/filtered. The release archive's lockfile still labelled workspace packages 0.0.0; the test copy normalized only those to 0.153.4, with all external package records identical. One initial existing marketplace-network test failed, then its isolated diagnostic rerun and final complete suite passed without functional changes; its initial cause remains unknown. Independent source review and candidate hash readback passed. There is an unused-field compiler warning in the retained command rewrite profile; no separate Clippy run is claimed. No installed app replacement or real user import was performed.

    Candidate diff against rust-v0.153.4 (11 source/test files)
    --- a/codex-rs/core-plugins/src/command_migration/render.rs
    +++ b/codex-rs/core-plugins/src/command_migration/render.rs
    @@ -1,68 +1,8 @@
     use super::RewriteProfile;
     
    -pub(super) fn rewrite_terms(content: &str, profile: RewriteProfile) -> String {
    -    let mut rewritten =
    -        replace_case_insensitive_with_boundaries(content, profile.doc_file_name, "AGENTS.md");
    -    for from in profile.term_variants {
    -        rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
    -    }
    -    for from in profile.case_sensitive_term_variants {
    -        rewritten = replace_with_boundaries(&rewritten, from, "Codex");
    -    }
    -    rewritten
    -}
    -
    -fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> String {
    -    if needle.is_empty() {
    -        return input.to_string();
    -    }
    -
    -    replace_with_boundaries_impl(input, needle, replacement, input)
    -}
    -
    -fn replace_case_insensitive_with_boundaries(
    -    input: &str,
    -    needle: &str,
    -    replacement: &str,
    -) -> String {
    -    let needle_lower = needle.to_ascii_lowercase();
    -    if needle_lower.is_empty() {
    -        return input.to_string();
    -    }
    -    let haystack_lower = input.to_ascii_lowercase();
    -    replace_with_boundaries_impl(input, &needle_lower, replacement, &haystack_lower)
    -}
    -
    -fn replace_with_boundaries_impl(
    -    input: &str,
    -    needle: &str,
    -    replacement: &str,
    -    searchable_input: &str,
    -) -> String {
    -    let bytes = input.as_bytes();
    -    let mut output = String::with_capacity(input.len());
    -    let mut last_emitted = 0usize;
    -    let mut search_start = 0usize;
    -
    -    while let Some(relative_pos) = searchable_input[search_start..].find(needle) {
    -        let start = search_start + relative_pos;
    -        let end = start + needle.len();
    -        let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
    -        let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
    -
    -        if boundary_before && boundary_after {
    -            output.push_str(&input[last_emitted..start]);
    -            output.push_str(replacement);
    -            last_emitted = end;
    -        }
    -        search_start = start + 1;
    -    }
    -
    -    if last_emitted == 0 {
    -        return input.to_string();
    -    }
    -    output.push_str(&input[last_emitted..]);
    -    output
    +// Source prose can contain literal paths and commands; migrate only the wrapper.
    +pub(super) fn rewrite_terms(content: &str, _profile: RewriteProfile) -> String {
    +    content.to_string()
     }
     
     pub(super) fn yaml_string(value: &str) -> String {
    @@ -89,7 +29,3 @@
             slug
         }
     }
    -
    -fn is_word_byte(byte: u8) -> bool {
    -    byte.is_ascii_alphanumeric() || byte == b'_'
    -}
    --- a/codex-rs/core-plugins/src/command_migration_tests.rs
    +++ b/codex-rs/core-plugins/src/command_migration_tests.rs
    @@ -144,3 +144,43 @@
             Vec::<CommandSource>::new()
         );
     }
    +
    +#[test]
    +fn imported_command_preserves_source_literals_and_existing_target() {
    +    let root = tempfile::TempDir::new().expect("tempdir");
    +    let commands = root.path().join("commands");
    +    let skills = root.path().join("skills");
    +    fs::create_dir_all(&commands).expect("commands");
    +    let description = "Use Claude Code with claude-shared-context";
    +    let body = "Read ~/.claude/CLAUDE.md and run `claude --model claude-sonnet-4`.\n数据源:Claude";
    +    fs::write(
    +        commands.join("audit.md"),
    +        format!("---\ndescription: {description}\n---\n{body}\n"),
    +    )
    +    .expect("source");
    +    let profile = CommandMigrationProfile::new(
    +        TEST_REWRITE_PROFILE,
    +        CommandDescriptionMode::RequireFrontmatter,
    +    );
    +    assert_eq!(
    +        import_commands_with_profile(&commands, &skills, profile).expect("import"),
    +        vec!["source-command-audit".to_string()]
    +    );
    +    let target = skills.join("source-command-audit/SKILL.md");
    +    let rendered = fs::read_to_string(&target).expect("target");
    +    assert_eq!(
    +        parse_command_content(&rendered).description.as_deref(),
    +        Some(description)
    +    );
    +    assert!(rendered.ends_with(&format!("{body}\n")));
    +    fs::write(&target, "user maintained target").expect("existing target");
    +    assert!(
    +        import_commands_with_profile(&commands, &skills, profile)
    +            .expect("reimport")
    +            .is_empty()
    +    );
    +    assert_eq!(
    +        fs::read_to_string(&target).expect("read existing"),
    +        "user maintained target"
    +    );
    +}
    --- a/codex-rs/external-agent-migration/src/hooks_cur_tests.rs
    +++ b/codex-rs/external-agent-migration/src/hooks_cur_tests.rs
    @@ -75,7 +75,7 @@
                             "type": "command",
                             "command": format!("sh '{}'", rewritten_script.display()),
                             "timeout": 7,
    -                        "statusMessage": "Codex check"
    +                        "statusMessage": "Source agent check"
                         }]
                     }],
                     "UserPromptSubmit": [{
    --- a/codex-rs/external-agent-migration/src/lib_tests.rs
    +++ b/codex-rs/external-agent-migration/src/lib_tests.rs
    @@ -786,3 +786,24 @@
             .expect("object")
         );
     }
    +
    +#[test]
    +fn skill_copy_preserves_source_literals_and_nested_assets() {
    +    let root = tempfile::TempDir::new().expect("tempdir");
    +    let source = root.path().join("source");
    +    let target = root.path().join("target");
    +    fs::create_dir_all(source.join("assets")).expect("source");
    +    let text = "---\nname: claude-audit\ndescription: Claude Code\n---\nRead ~/.claude/CLAUDE.md and claude-shared-context.\n```sh\nclaude --model claude-sonnet-4\n```\n数据源:Claude\n";
    +    fs::write(source.join("SKILL.md"), text).expect("skill");
    +    let bytes = [0, 255, 128, 65];
    +    fs::write(source.join("assets/data.bin"), bytes).expect("asset");
    +    super::utils::copy_dir_recursive(&source, &target).expect("copy");
    +    assert_eq!(
    +        fs::read_to_string(target.join("SKILL.md")).expect("skill"),
    +        text
    +    );
    +    assert_eq!(
    +        fs::read(target.join("assets/data.bin")).expect("asset"),
    +        bytes
    +    );
    +}
    --- a/codex-rs/external-agent-migration/src/rewrite.rs
    +++ b/codex-rs/external-agent-migration/src/rewrite.rs
    @@ -35,94 +35,11 @@
             self.case_sensitive_term_variants
         }
     
    -    /// Rewrites source-specific documentation names and product terms to their Codex forms.
    +    /// Preserve source text: product names may be paths, commands, model IDs or citations.
    +    /// Structural migration (target filenames, fields and hook paths) is handled separately.
         pub fn rewrite(self, content: &str) -> String {
    -        let mut rewritten =
    -            replace_case_insensitive_with_boundaries(content, self.doc_file_name, "AGENTS.md");
    -        for from in self.term_variants {
    -            rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
    -        }
    -        for from in self.case_sensitive_term_variants {
    -            rewritten = replace_with_boundaries(&rewritten, from, "Codex");
    -        }
    -        rewritten
    +        content.to_string()
         }
    -}
    -
    -fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> String {
    -    if needle.is_empty() {
    -        return input.to_string();
    -    }
    -
    -    let bytes = input.as_bytes();
    -    let mut output = String::with_capacity(input.len());
    -    let mut last_emitted = 0usize;
    -    let mut search_start = 0usize;
    -
    -    while let Some(relative_pos) = input[search_start..].find(needle) {
    -        let start = search_start + relative_pos;
    -        let end = start + needle.len();
    -        let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
    -        let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
    -
    -        if boundary_before && boundary_after {
    -            output.push_str(&input[last_emitted..start]);
    -            output.push_str(replacement);
    -            last_emitted = end;
    -        }
    -
    -        search_start = end;
    -    }
    -
    -    if last_emitted == 0 {
    -        return input.to_string();
    -    }
    -
    -    output.push_str(&input[last_emitted..]);
    -    output
    -}
    -
    -fn replace_case_insensitive_with_boundaries(
    -    input: &str,
    -    needle: &str,
    -    replacement: &str,
    -) -> String {
    -    let needle_lower = needle.to_ascii_lowercase();
    -    if needle_lower.is_empty() {
    -        return input.to_string();
    -    }
    -
    -    let haystack_lower = input.to_ascii_lowercase();
    -    let bytes = input.as_bytes();
    -    let mut output = String::with_capacity(input.len());
    -    let mut last_emitted = 0usize;
    -    let mut search_start = 0usize;
    -
    -    while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) {
    -        let start = search_start + relative_pos;
    -        let end = start + needle_lower.len();
    -        let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
    -        let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
    -
    -        if boundary_before && boundary_after {
    -            output.push_str(&input[last_emitted..start]);
    -            output.push_str(replacement);
    -            last_emitted = end;
    -        }
    -
    -        search_start = start + 1;
    -    }
    -
    -    if last_emitted == 0 {
    -        return input.to_string();
    -    }
    -
    -    output.push_str(&input[last_emitted..]);
    -    output
    -}
    -
    -fn is_word_byte(byte: u8) -> bool {
    -    byte.is_ascii_alphanumeric() || byte == b'_'
     }
     
     #[cfg(test)]
    --- a/codex-rs/external-agent-migration/src/rewrite_tests.rs
    +++ b/codex-rs/external-agent-migration/src/rewrite_tests.rs
    @@ -1,13 +1,20 @@
     use super::*;
     use pretty_assertions::assert_eq;
     
    -const PROFILE: RewriteProfile = RewriteProfile::new("SOURCE.md", &["source agent"])
    -    .with_case_sensitive_term_variants(&["Source"]);
    -
     #[test]
    -fn rewrites_terms_only_at_word_boundaries() {
    -    assert_eq!(
    -        PROFILE.rewrite("SOURCE.md Source source agent source_agent"),
    -        "AGENTS.md Codex Codex source_agent"
    -    );
    +fn source_text_is_preserved_for_all_profiles() {
    +    for profile in [
    +        RewriteProfile::new("CLAUDE.md", &["claude code", "claude"]),
    +        RewriteProfile::new("SOURCE.md", &["source agent"])
    +            .with_case_sensitive_term_variants(&["Source"]),
    +    ] {
    +        for text in [
    +            "",
    +            "Claude Code and Source agent",
    +            "~/.claude/CLAUDE.md; claude-shared-context; SOURCE.md",
    +            "```sh\nclaude --model claude-sonnet-4\n```\n数据源:Claude",
    +        ] {
    +            assert_eq!(profile.rewrite(text), text);
    +        }
    +    }
     }
    --- a/codex-rs/external-agent-migration/src/service.rs
    +++ b/codex-rs/external-agent-migration/src/service.rs
    @@ -737,7 +737,7 @@
                         continue;
                     }
     
    -                copy_dir_recursive(&entry.path(), &target, self.source.rewrite_profile())?;
    +                copy_dir_recursive(&entry.path(), &target)?;
                     copied_names.push(entry.file_name().to_string_lossy().to_string());
                 }
             }
    --- a/codex-rs/external-agent-migration/src/service_tests/general/config_import.rs
    +++ b/codex-rs/external-agent-migration/src/service_tests/general/config_import.rs
    @@ -174,7 +174,7 @@
     
         assert_eq!(
             fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"),
    -        "Codex guidance"
    +        format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance")
         );
     
         let config: TomlValue =
    @@ -199,7 +199,9 @@
         assert_eq!(
             fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
                 .expect("read copied skill"),
    -        "Use Codex and Codex utilities."
    +        format!(
    +            "Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities."
    +        )
         );
     }
     
    --- a/codex-rs/external-agent-migration/src/service_tests/general/detection.rs
    +++ b/codex-rs/external-agent-migration/src/service_tests/general/detection.rs
    @@ -128,7 +128,7 @@
                         .join("SKILL.md")
                 )
                 .expect("read migrated command"),
    -            "---\nname: \"source-command-pr-review\"\ndescription: \"Migrated source command `pr-review`\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nReview changes with Codex and AGENTS.md.\n"
    +            "---\nname: \"source-command-pr-review\"\ndescription: \"Migrated source command `pr-review`\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nReview changes with Claude Code and CLAUDE.md.\n"
             );
             assert!(!target_skills.join("source-command-deploy").exists());
             assert_eq!(
    @@ -878,7 +878,7 @@
     model_reasoning_effort = "high"
     sandbox_mode = "workspace-write"
     developer_instructions = """
    -Research with Codex carefully."""
    +Research with Claude Code carefully."""
     "#,
         )
         .expect("parse expected agent");
    --- a/codex-rs/external-agent-migration/src/service_tests/general/repo_import.rs
    +++ b/codex-rs/external-agent-migration/src/service_tests/general/repo_import.rs
    @@ -73,7 +73,7 @@
     }
     
     #[tokio::test]
    -async fn import_repo_agents_md_from_nested_cwd_rewrites_terms_and_skips_non_empty_targets() {
    +async fn import_repo_agents_md_from_nested_cwd_preserves_terms_and_skips_non_empty_targets() {
         let root = TempDir::new().expect("create tempdir");
         let repo_root = root.path().join("repo-a");
         let nested_cwd = repo_root.join("nested");
    @@ -152,7 +152,9 @@
         );
         assert_eq!(
             fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
    -        "Codex\nCodex\nCodex\nSee AGENTS.md\n"
    +        format!(
    +            "{SOURCE_EXTERNAL_AGENT_PRODUCT_NAME}\n{SOURCE_EXTERNAL_AGENT_NAME}\n{SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME}\nSee {EXTERNAL_AGENT_CONFIG_MD}\n"
    +        )
         );
         assert_eq!(
             fs::read_to_string(repo_with_existing_target.join("AGENTS.md"))
    @@ -207,7 +209,7 @@
         );
         assert_eq!(
             fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
    -        "Codex guidance"
    +        format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance")
         );
     }
     
    @@ -682,7 +684,7 @@
     
         assert_eq!(
             fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
    -        "Codex guidance"
    +        format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance")
         );
     }
     
    @@ -715,7 +717,7 @@
     
         assert_eq!(
             fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
    -        "Codex guidance"
    +        "Claude guidance"
         );
     }
     
    --- a/codex-rs/external-agent-migration/src/utils.rs
    +++ b/codex-rs/external-agent-migration/src/utils.rs
    @@ -41,11 +41,7 @@
         io::Error::new(io::ErrorKind::InvalidData, message.into())
     }
     
    -pub(super) fn copy_dir_recursive(
    -    source: &Path,
    -    target: &Path,
    -    rewrite_profile: RewriteProfile,
    -) -> io::Result<()> {
    +pub(super) fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
         fs::create_dir_all(target)?;
     
         for entry in fs::read_dir(source)? {
    @@ -55,16 +51,12 @@
             let file_type = entry.file_type()?;
     
             if file_type.is_dir() {
    -            copy_dir_recursive(&source_path, &target_path, rewrite_profile)?;
    +            copy_dir_recursive(&source_path, &target_path)?;
                 continue;
             }
     
             if file_type.is_file() {
    -            if is_skill_md(&source_path) {
    -                rewrite_and_copy_text_file(&source_path, &target_path, rewrite_profile)?;
    -            } else {
    -                fs::copy(source_path, target_path)?;
    -            }
    +            fs::copy(source_path, target_path)?;
             }
         }
     
    @@ -77,19 +69,3 @@
     ) -> String {
         rewrite_profile.rewrite(content)
     }
    -
    -fn is_skill_md(path: &Path) -> bool {
    -    path.file_name()
    -        .and_then(|name| name.to_str())
    -        .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
    -}
    -
    -fn rewrite_and_copy_text_file(
    -    source: &Path,
    -    target: &Path,
    -    rewrite_profile: RewriteProfile,
    -) -> io::Result<()> {
    -    let source_contents = fs::read_to_string(source)?;
    -    let rewritten = rewrite_external_agent_terms(&source_contents, rewrite_profile);
    -    fs::write(target, rewritten)
    -}
    
  9. roshne commented on Sep 28, 2026

    @roshne

    Seeing this in the Windows desktop app too (26.924.2738.0, Microsoft Store build). Two cases from my import that this issue doesn't list yet. Both come from the same rule in rewrite.rs, where /, - and . all count as word boundaries.

    1. Git branch names. A skill that names a branch convention (names shortened):

    Otherwise branch `claude/release-vX.Y.Z` from `origin/main`.
    gh pr list ... --jq '.[] | select(.headRefName | startswith("claude/plan-rev")) | .url'
    

    was imported as:

    Otherwise branch `Codex/release-vX.Y.Z` from `origin/main`.
    gh pr list ... --jq '.[] | select(.headRefName | startswith("Codex/plan-rev")) | .url'
    

    An agent following the imported copy creates branches under a prefix nothing else uses, and the startswith filter, a guard that looks for open PRs on those branches, silently matches nothing. A branch name isn't a path, but going by the v5 description above, the /-adjacency check would keep it. A branch-prefix case would be worth adding to the regression tests.

    2. Hyphenated names, where a longer term variant eats part of the name. claude-code is one of the Claude term variants in source/cla.rs, so a folder named claude-code-review became Codex-review, not Codex-code-review:

    %LOCALAPPDATA%\claude-code-review  ->  %LOCALAPPDATA%\Codex-review
    ~/.local/state/claude-code-review  ->  ~/.local/state/Codex-review
    

    A Codex run of that skill then writes its output to a different folder from the Claude run. These two are path-adjacent, so the v5 check would keep them. A bare hyphenated name in prose, such as a repo called claude-memory-sync, is bounded only by -, so going by the description it would still be rewritten.

    Related: imported copies never refresh. import_skills in service.rs skips any target that already exists, so an imported skill is never updated when its source changes, even with external-agent-import-sync-enabled on. I found an imported copy that still matched an older version of its source skill. Copies also appeared inside git worktrees under <repo>/.claude/worktrees/, so one skill can have several stale, rewritten copies.

    Workaround I'm using: SKILLS = false under [desktop.external-agent-import-sync-item-types], which leaves the rest of the sync on, then restoring each imported SKILL.md from its source.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appbugSomething isn't workingskillsIssues related to skills

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions