You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Claude Code skill import rewrites .claude/ paths to .Codex/, producing directories that don't exist #40147
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
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
Let the Codex desktop onboarding import run ([desktop] external-agent-import-sync-enabled = true).
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:
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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)-}
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:
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.
Activity
chowdhuryaj commentedon Aug 22, 2026
Follow-up: the same "rewrite assumes the target exists" flaw applies to the
CLAUDE.md→AGENTS.mdsubstitution, which I'd called correct in the originalreport. 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:
CLAUDE.mdAGENTS.mdFor project B the imported skill says "read
AGENTS.mdin the repo root — it isauthoritative", 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 toAGENTS.mdif<dir>/AGENTS.mdexists; otherwise leave the original. That is acheap
statper candidate and it degrades safely — worst case you keep a namethat is merely off-brand, instead of emitting one that is wrong.
The same conditional would fix the path case in the original report:
.claude/xshould only become
.codex/xif~/.codex/xexists.matebenyovszky commentedon Aug 22, 2026
Hit this on mixed Claude Code + Codex machines too. Blind
claude→Codexinside a skill body is the wrong layer:~/.claude/projects/is a real directory, andCLAUDE.md→AGENTS.mdis 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 --applyThat writes
.codex/skillsand.codex/config.tomlwithout substitutingclaudeinsideSKILL.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.charle-z commentedon Aug 22, 2026
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-v4Final head:
cc3b3697867dbd3854c84c90982bca05f836afe6Exact upstream base:
c9b19deb09c1841ce7acc33ddb96276030936a29Branch: 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.rscodex-rs/external-agent-migration/src/rewrite_tests.rsThe 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, andC:\\Users\\...\\.claude\\plans, while standaloneClaude Codeprose still becomesCodex.Exact-current-main focused validation is fully green:
cc3b369..., 1 ahead / 0 behind / exactly 2 files)just test -p codex-external-agent-migration: passedjust fix -p codex-external-agent-migration: passedgit diff --exit-code) check: passedValidation run: https://github.com/charle-z/codex/actions/runs/32652611137
The validation base made the unrelated
blocking-ciandv8-canaryworkflows 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.mdtoken may refer to a real file but noAGENTS.mdexists.RewriteProfile::rewrite()receives content only and is shared across skills/subagents/hooks, so there is no trustworthy filesystem base for astat()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.charle-z commentedon Aug 26, 2026
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, andclaude://session?id=claude.Updated branch:
charle-z:fix/40147-preserve-external-agent-references-v5Head:
46d20eff12cfc8611194e47fa19a8e696192a976Upstream base:
10d5a603aecbd73a38f3a6576cce69a78f8d6f1dv5 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; standaloneCLAUDE.md->AGENTS.mdbehavior is intentionally unchanged.Validation: Rust 1.95.0, cargo-nextest 0.9.143; focal URL regression 1/1;
codex-external-agent-migration175/175; Clippy with-D warnings;just fix;just fmt;git diff --check. Diff remains limited torewrite.rsandrewrite_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.
ci: validate openai#40147 v6 against current main
ci: validate openai#40147 v7 against latest main
charle-z commentedon Sep 6, 2026
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.rsandrewrite_tests.rs. All 182 tests passed withjust test -p codex-external-agent-migrationon Linux/WSL, Rust 1.95.0. Scopedjust fix,just fmtand the clean-tree check passed too.The separate
CLAUDE.mdconcern above remains: standalone names are still rewritten, and this patch does not check whether the destinationAGENTS.mdexists.josh87523 commentedon Sep 9, 2026
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 --libpassed 439 + 169 = 608 tests, zero ignored/filtered. The release archive's lockfile still labelled workspace packages0.0.0; the test copy normalized only those to0.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)
roshne commentedon Sep 28, 2026
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):
was imported as:
An agent following the imported copy creates branches under a prefix nothing else uses, and the
startswithfilter, 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-codeis one of the Claude term variants insource/cla.rs, so a folder namedclaude-code-reviewbecameCodex-review, notCodex-code-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_skillsinservice.rsskips any target that already exists, so an imported skill is never updated when its source changes, even withexternal-agent-import-sync-enabledon. 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 = falseunder[desktop.external-agent-import-sync-item-types], which leaves the rest of the sync on, then restoring each importedSKILL.mdfrom its source.