Support another external agent import source

This commit is contained in:
charlesgong-openai
2026-07-08 13:38:21 -04:00
parent dd975f36cd
commit 9e242a5eab
15 changed files with 644 additions and 181 deletions
+8 -1
View File
@@ -810,6 +810,13 @@
"includeHome": {
"description": "If true, include detection under the user's home directory.",
"type": "boolean"
},
"source": {
"description": "Source product to detect. Missing or unrecognized values use Claude Code for backwards compatibility; `cursor` selects Cursor.",
"type": [
"string",
"null"
]
}
},
"type": "object"
@@ -823,7 +830,7 @@
"type": "array"
},
"source": {
"description": "Source product that produced the migration items. Missing means unspecified.",
"description": "Source product that produced the migration items. Use `cursor` for Cursor items; missing or unrecognized values use Claude Code for backwards compatibility.",
"type": [
"string",
"null"
@@ -9237,6 +9237,13 @@
"includeHome": {
"description": "If true, include detection under the user's home directory.",
"type": "boolean"
},
"source": {
"description": "Source product to detect. Missing or unrecognized values use Claude Code for backwards compatibility; `cursor` selects Cursor.",
"type": [
"string",
"null"
]
}
},
"title": "ExternalAgentConfigDetectParams",
@@ -9400,7 +9407,7 @@
"type": "array"
},
"source": {
"description": "Source product that produced the migration items. Missing means unspecified.",
"description": "Source product that produced the migration items. Use `cursor` for Cursor items; missing or unrecognized values use Claude Code for backwards compatibility.",
"type": [
"string",
"null"
@@ -5477,6 +5477,13 @@
"includeHome": {
"description": "If true, include detection under the user's home directory.",
"type": "boolean"
},
"source": {
"description": "Source product to detect. Missing or unrecognized values use Claude Code for backwards compatibility; `cursor` selects Cursor.",
"type": [
"string",
"null"
]
}
},
"title": "ExternalAgentConfigDetectParams",
@@ -5640,7 +5647,7 @@
"type": "array"
},
"source": {
"description": "Source product that produced the migration items. Missing means unspecified.",
"description": "Source product that produced the migration items. Use `cursor` for Cursor items; missing or unrecognized values use Claude Code for backwards compatibility.",
"type": [
"string",
"null"
@@ -14,6 +14,13 @@
"includeHome": {
"description": "If true, include detection under the user's home directory.",
"type": "boolean"
},
"source": {
"description": "Source product to detect. Missing or unrecognized values use Claude Code for backwards compatibility; `cursor` selects Cursor.",
"type": [
"string",
"null"
]
}
},
"title": "ExternalAgentConfigDetectParams",
@@ -204,7 +204,7 @@
"type": "array"
},
"source": {
"description": "Source product that produced the migration items. Missing means unspecified.",
"description": "Source product that produced the migration items. Use `cursor` for Cursor items; missing or unrecognized values use Claude Code for backwards compatibility.",
"type": [
"string",
"null"
@@ -10,4 +10,9 @@ includeHome?: boolean,
/**
* Zero or more working directories to include for repo-scoped detection.
*/
cwds?: Array<string> | null, };
cwds?: Array<string> | null,
/**
* Source product to detect. Missing or unrecognized values use Claude Code for backwards
* compatibility; `cursor` selects Cursor.
*/
source?: string | null, };
@@ -5,6 +5,7 @@ import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr
export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>,
/**
* Source product that produced the migration items. Missing means unspecified.
* Source product that produced the migration items. Use `cursor` for Cursor items; missing or
* unrecognized values use Claude Code for backwards compatibility.
*/
source?: string | null, };
@@ -684,6 +684,10 @@ pub struct ExternalAgentConfigDetectParams {
/// Zero or more working directories to include for repo-scoped detection.
#[ts(optional = nullable)]
pub cwds: Option<Vec<PathBuf>>,
/// Source product to detect. Missing or unrecognized values use Claude Code for backwards
/// compatibility; `cursor` selects Cursor.
#[ts(optional = nullable)]
pub source: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -691,7 +695,8 @@ pub struct ExternalAgentConfigDetectParams {
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportParams {
pub migration_items: Vec<ExternalAgentConfigMigrationItem>,
/// Source product that produced the migration items. Missing means unspecified.
/// Source product that produced the migration items. Use `cursor` for Cursor items; missing or
/// unrecognized values use Claude Code for backwards compatibility.
#[ts(optional = nullable)]
pub source: Option<String>,
}
+2 -2
View File
@@ -239,8 +239,8 @@ Example with notification opt-out:
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and optional `source`. Omitted or unrecognized `source` values preserve the existing Claude Code behavior; `source: "cursor"` detects portable Cursor rules, skills, commands, subagents, and MCP configuration. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Pass the same `source` used for detection so the importer reads the correct product paths; omitted, `null`, or unrecognized values use Claude Code for backwards compatibility. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
@@ -1,3 +1,5 @@
mod cursor;
use codex_config::types::PluginConfig;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
@@ -8,11 +10,14 @@ use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_add::is_local_marketplace_source;
use codex_external_agent_migration::build_mcp_config_from_cursor;
use codex_external_agent_migration::build_mcp_config_from_external;
use codex_external_agent_migration::count_missing_commands;
use codex_external_agent_migration::count_missing_subagents;
use codex_external_agent_migration::hook_migration_event_names;
use codex_external_agent_migration::import_commands;
use codex_external_agent_migration::import_cursor_commands;
use codex_external_agent_migration::import_cursor_subagents;
use codex_external_agent_migration::import_hooks;
use codex_external_agent_migration::import_subagents;
use codex_external_agent_migration::missing_command_names;
@@ -36,9 +41,35 @@ const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.d
const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
const EXTERNAL_AGENT_DIR: &str = ".claude";
const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md";
const CURSOR_AGENT_DIR: &str = ".cursor";
const CURSOR_LEGACY_RULES_FILE: &str = ".cursorrules";
const EXTERNAL_OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official";
const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum ExternalAgentSource {
#[default]
ClaudeCode,
Cursor,
}
impl ExternalAgentSource {
fn from_api_source(source: Option<&str>) -> Self {
if source.is_some_and(|source| source.eq_ignore_ascii_case("cursor")) {
Self::Cursor
} else {
Self::ClaudeCode
}
}
fn config_dir(self) -> &'static str {
match self {
Self::ClaudeCode => EXTERNAL_AGENT_DIR,
Self::Cursor => CURSOR_AGENT_DIR,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExternalAgentConfigDetectOptions {
pub include_home: bool,
@@ -176,22 +207,36 @@ pub(crate) struct ExternalAgentConfigMigrationItem {
pub(crate) struct ExternalAgentConfigService {
codex_home: PathBuf,
external_agent_home: PathBuf,
source: ExternalAgentSource,
}
impl ExternalAgentConfigService {
pub(crate) fn new(codex_home: PathBuf) -> Self {
let external_agent_home = default_external_agent_home();
Self::new_for_source(codex_home, ExternalAgentSource::ClaudeCode)
}
fn new_for_source(codex_home: PathBuf, source: ExternalAgentSource) -> Self {
let external_agent_home = default_external_agent_home(source);
Self {
codex_home,
external_agent_home,
source,
}
}
pub(crate) fn with_api_source(&self, source: Option<&str>) -> Self {
Self::new_for_source(
self.codex_home.clone(),
ExternalAgentSource::from_api_source(source),
)
}
#[cfg(test)]
fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
Self {
codex_home,
external_agent_home,
source: ExternalAgentSource::ClaudeCode,
}
}
@@ -219,6 +264,9 @@ impl ExternalAgentConfigService {
&self,
path: &Path,
) -> io::Result<Option<PathBuf>> {
if self.source != ExternalAgentSource::ClaudeCode {
return Ok(None);
}
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
return Ok(None);
}
@@ -444,11 +492,12 @@ impl ExternalAgentConfigService {
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = repo_root.map_or_else(
|| self.external_agent_home.join("settings.json"),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let settings = effective_external_settings(&source_settings)?;
let source_settings = self.source_settings(repo_root);
let settings = if self.source == ExternalAgentSource::ClaudeCode {
effective_external_settings(&source_settings)?
} else {
None
};
let target_config = repo_root.map_or_else(
|| self.codex_home.join("config.toml"),
|repo_root| repo_root.join(".codex").join("config.toml"),
@@ -489,13 +538,7 @@ impl ExternalAgentConfigService {
}
}
let source_root = self.source_root(repo_root);
let mcp_settings = self.mcp_settings(repo_root, settings.clone())?;
let migrated_mcp = build_mcp_config_from_external(
source_root.as_path(),
Some(self.external_agent_home.as_path()),
mcp_settings.as_ref(),
)?;
let migrated_mcp = self.build_mcp_config(repo_root, settings.clone())?;
let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
if !is_empty_toml_table(&migrated_mcp) {
if target_config.exists() {
@@ -515,7 +558,7 @@ impl ExternalAgentConfigService {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: format!(
"Migrate MCP servers from {} into {}",
source_root.display(),
self.mcp_source_path(repo_root).display(),
target_config.display()
),
cwd: cwd.clone(),
@@ -532,16 +575,16 @@ impl ExternalAgentConfigService {
}
}
let source_external_agent_dir = repo_root.map_or_else(
|| self.external_agent_home.clone(),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR),
);
let source_external_agent_dir = self.source_config_dir(repo_root);
let target_hooks = repo_root.map_or_else(
|| self.codex_home.join("hooks.json"),
|repo_root| repo_root.join(".codex").join("hooks.json"),
);
let hook_event_names =
hook_migration_event_names(source_external_agent_dir.as_path(), &target_hooks)?;
let hook_event_names = if self.source == ExternalAgentSource::ClaudeCode {
hook_migration_event_names(source_external_agent_dir.as_path(), &target_hooks)?
} else {
Vec::new()
};
if !hook_event_names.is_empty() && is_missing_or_empty_text_file(&target_hooks)? {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Hooks,
@@ -565,7 +608,7 @@ impl ExternalAgentConfigService {
let source_skills = repo_root.map_or_else(
|| self.external_agent_home.join("skills"),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
|repo_root| repo_root.join(self.source.config_dir()).join("skills"),
);
let target_skills = repo_root.map_or_else(
|| self.home_target_skills_dir(),
@@ -651,23 +694,20 @@ impl ExternalAgentConfigService {
}
let source_agents_md = if let Some(repo_root) = repo_root {
find_repo_agents_md_source(repo_root)?
self.repo_agents_md_sources(repo_root)?
} else {
let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD);
is_non_empty_text_file(&path)?.then_some(path)
self.home_agents_md_sources()?
};
let target_agents_md = repo_root.map_or_else(
|| self.codex_home.join("AGENTS.md"),
|repo_root| repo_root.join("AGENTS.md"),
);
if let Some(source_agents_md) = source_agents_md
&& is_missing_or_empty_text_file(&target_agents_md)?
{
if !source_agents_md.is_empty() && is_missing_or_empty_text_file(&target_agents_md)? {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Migrate {} to {}",
source_agents_md.display(),
display_source_paths(&source_agents_md),
target_agents_md.display()
),
cwd: cwd.clone(),
@@ -680,7 +720,9 @@ impl ExternalAgentConfigService {
);
}
if let Some(settings) = settings.as_ref() {
if self.source == ExternalAgentSource::ClaudeCode
&& let Some(settings) = settings.as_ref()
{
match ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.fallback_cwd(Some(self.codex_home.clone()))
@@ -728,7 +770,7 @@ impl ExternalAgentConfigService {
}
}
if repo_root.is_none() {
if repo_root.is_none() && self.source == ExternalAgentSource::ClaudeCode {
let sessions = detect_recent_sessions(&self.external_agent_home, &self.codex_home)?;
if !sessions.is_empty() {
items.push(ExternalAgentConfigMigrationItem {
@@ -761,6 +803,61 @@ impl ExternalAgentConfigService {
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"))
}
fn source_config_dir(&self, repo_root: Option<&Path>) -> PathBuf {
repo_root.map_or_else(
|| self.external_agent_home.clone(),
|repo_root| repo_root.join(self.source.config_dir()),
)
}
fn source_settings(&self, repo_root: Option<&Path>) -> PathBuf {
self.source_config_dir(repo_root).join("settings.json")
}
fn mcp_source_path(&self, repo_root: Option<&Path>) -> PathBuf {
match self.source {
ExternalAgentSource::ClaudeCode => self.source_root(repo_root),
ExternalAgentSource::Cursor => self.source_config_dir(repo_root).join("mcp.json"),
}
}
fn build_mcp_config(
&self,
repo_root: Option<&Path>,
settings: Option<JsonValue>,
) -> io::Result<TomlValue> {
match self.source {
ExternalAgentSource::ClaudeCode => build_mcp_config_from_external(
self.source_root(repo_root).as_path(),
Some(self.external_agent_home.as_path()),
settings.as_ref(),
),
ExternalAgentSource::Cursor => {
build_mcp_config_from_cursor(self.source_config_dir(repo_root).as_path())
}
}
}
fn repo_agents_md_sources(&self, repo_root: &Path) -> io::Result<Vec<PathBuf>> {
match self.source {
ExternalAgentSource::ClaudeCode => {
Ok(find_repo_agents_md_source(repo_root)?.into_iter().collect())
}
ExternalAgentSource::Cursor => cursor::rule_sources(repo_root),
}
}
fn home_agents_md_sources(&self) -> io::Result<Vec<PathBuf>> {
if self.source != ExternalAgentSource::ClaudeCode {
return Ok(Vec::new());
}
let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD);
Ok(is_non_empty_text_file(&path)?
.then_some(path)
.into_iter()
.collect())
}
fn mcp_settings(
&self,
repo_root: Option<&Path>,
@@ -830,10 +927,7 @@ impl ExternalAgentConfigService {
cwd: Option<&Path>,
details: MigrationDetails,
) -> io::Result<(Option<MigrationDetails>, Option<MigrationDetails>)> {
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let source_settings = self.source_settings(cwd);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_sources = effective_external_settings(&source_settings)?
.map(|settings| collect_marketplace_import_sources(&settings, source_root))
@@ -901,10 +995,7 @@ impl ExternalAgentConfigService {
.iter()
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
.collect::<Vec<_>>();
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let source_settings = self.source_settings(cwd);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_source =
effective_external_settings(&source_settings)?.and_then(|settings| {
@@ -1024,6 +1115,9 @@ impl ExternalAgentConfigService {
}
fn import_config(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
if self.source != ExternalAgentSource::ClaudeCode {
return Ok(None);
}
let repo_root = find_repo_root(cwd)?;
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
(
@@ -1082,7 +1176,7 @@ impl ExternalAgentConfigService {
let repo_root = find_repo_root(cwd)?;
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
self.source_settings(Some(repo_root)),
repo_root.join(".codex").join("config.toml"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
@@ -1093,15 +1187,15 @@ impl ExternalAgentConfigService {
self.codex_home.join("config.toml"),
)
};
let settings = self.mcp_settings(
repo_root.as_deref(),
effective_external_settings(&source_settings)?,
)?;
let migrated = build_mcp_config_from_external(
self.source_root(repo_root.as_deref()).as_path(),
Some(self.external_agent_home.as_path()),
settings.as_ref(),
)?;
let settings = if self.source == ExternalAgentSource::ClaudeCode {
self.mcp_settings(
repo_root.as_deref(),
effective_external_settings(&source_settings)?,
)?
} else {
None
};
let migrated = self.build_mcp_config(repo_root.as_deref(), settings)?;
if is_empty_toml_table(&migrated) {
return Ok(Vec::new());
}
@@ -1133,7 +1227,7 @@ impl ExternalAgentConfigService {
fn import_subagents(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let (source_agents, target_agents) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("agents"),
repo_root.join(self.source.config_dir()).join("agents"),
repo_root.join(".codex").join("agents"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
@@ -1145,14 +1239,20 @@ impl ExternalAgentConfigService {
)
};
import_subagents(&source_agents, &target_agents)
match self.source {
ExternalAgentSource::ClaudeCode => import_subagents(&source_agents, &target_agents),
ExternalAgentSource::Cursor => import_cursor_subagents(&source_agents, &target_agents),
}
}
fn import_hooks(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
if self.source != ExternalAgentSource::ClaudeCode {
return Ok(Vec::new());
}
let (source_external_agent_dir, target_hooks) =
if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR),
repo_root.join(self.source.config_dir()),
repo_root.join(".codex").join("hooks.json"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
@@ -1175,7 +1275,7 @@ impl ExternalAgentConfigService {
fn import_commands(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let (source_commands, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("commands"),
repo_root.join(self.source.config_dir()).join("commands"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
@@ -1187,13 +1287,16 @@ impl ExternalAgentConfigService {
)
};
import_commands(&source_commands, &target_skills)
match self.source {
ExternalAgentSource::ClaudeCode => import_commands(&source_commands, &target_skills),
ExternalAgentSource::Cursor => import_cursor_commands(&source_commands, &target_skills),
}
}
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
repo_root.join(self.source.config_dir()).join("skills"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
@@ -1223,7 +1326,7 @@ impl ExternalAgentConfigService {
continue;
}
copy_dir_recursive(&entry.path(), &target)?;
copy_dir_recursive(&entry.path(), &target, self.source)?;
copied_names.push(entry.file_name().to_string_lossy().to_string());
}
@@ -1232,21 +1335,21 @@ impl ExternalAgentConfigService {
fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? {
let Some(source_agents_md) = find_repo_agents_md_source(&repo_root)? else {
let source_agents_md = self.repo_agents_md_sources(&repo_root)?;
if source_agents_md.is_empty() {
return Ok(None);
};
}
(source_agents_md, repo_root.join("AGENTS.md"))
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(None);
} else {
(
self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
self.codex_home.join("AGENTS.md"),
)
let source_agents_md = self.home_agents_md_sources()?;
if source_agents_md.is_empty() {
return Ok(None);
}
(source_agents_md, self.codex_home.join("AGENTS.md"))
};
if !is_non_empty_text_file(&source_agents_md)?
|| !is_missing_or_empty_text_file(&target_agents_md)?
{
if !is_missing_or_empty_text_file(&target_agents_md)? {
return Ok(None);
}
@@ -1255,20 +1358,25 @@ impl ExternalAgentConfigService {
};
fs::create_dir_all(target_parent)?;
rewrite_and_copy_text_file(&source_agents_md, &target_agents_md)?;
let source_contents = source_agents_md
.iter()
.map(|source| read_agents_md_source(source, self.source))
.collect::<io::Result<Vec<_>>>()?
.join("\n\n");
fs::write(&target_agents_md, source_contents)?;
Ok(Some((
source_agents_md.display().to_string(),
display_source_paths(&source_agents_md),
target_agents_md.display().to_string(),
)))
}
}
fn default_external_agent_home() -> PathBuf {
fn default_external_agent_home(source: ExternalAgentSource) -> PathBuf {
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(EXTERNAL_AGENT_DIR);
return PathBuf::from(home).join(source.config_dir());
}
PathBuf::from(EXTERNAL_AGENT_DIR)
PathBuf::from(source.config_dir())
}
fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
@@ -1620,7 +1728,31 @@ fn find_repo_agents_md_source(repo_root: &Path) -> io::Result<Option<PathBuf>> {
Ok(None)
}
fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
fn read_agents_md_source(path: &Path, source: ExternalAgentSource) -> io::Result<String> {
let contents = fs::read_to_string(path)?;
let contents = if source == ExternalAgentSource::Cursor
&& path.extension().and_then(|extension| extension.to_str()) == Some("mdc")
{
cursor::strip_markdown_frontmatter(&contents)
} else {
&contents
};
Ok(rewrite_external_agent_terms(contents, source))
}
fn display_source_paths(paths: &[PathBuf]) -> String {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn copy_dir_recursive(
source: &Path,
target: &Path,
source_agent: ExternalAgentSource,
) -> io::Result<()> {
fs::create_dir_all(target)?;
for entry in fs::read_dir(source)? {
@@ -1630,13 +1762,13 @@ fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
copy_dir_recursive(&source_path, &target_path, source_agent)?;
continue;
}
if file_type.is_file() {
if is_skill_md(&source_path) {
rewrite_and_copy_text_file(&source_path, &target_path)?;
rewrite_and_copy_text_file(&source_path, &target_path, source_agent)?;
} else {
fs::copy(source_path, target_path)?;
}
@@ -1652,25 +1784,36 @@ fn is_skill_md(path: &Path) -> bool {
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
}
fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> {
fn rewrite_and_copy_text_file(
source: &Path,
target: &Path,
source_agent: ExternalAgentSource,
) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_external_agent_terms(&source_contents);
let rewritten = rewrite_external_agent_terms(&source_contents, source_agent);
fs::write(target, rewritten)
}
fn rewrite_external_agent_terms(content: &str) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(
content,
&EXTERNAL_AGENT_CONFIG_MD.to_ascii_lowercase(),
"AGENTS.md",
);
for from in [
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
] {
fn rewrite_external_agent_terms(content: &str, source: ExternalAgentSource) -> String {
let (doc_file_name, term_variants): (&str, &[&str]) = match source {
ExternalAgentSource::ClaudeCode => (
EXTERNAL_AGENT_CONFIG_MD,
&[
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
],
),
ExternalAgentSource::Cursor => (
CURSOR_LEGACY_RULES_FILE,
&["cursor agent", "cursor-agent", "cursor"],
),
};
let mut rewritten =
replace_case_insensitive_with_boundaries(content, doc_file_name, "AGENTS.md");
for from in term_variants {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
}
rewritten
@@ -0,0 +1,86 @@
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use super::CURSOR_AGENT_DIR;
use super::CURSOR_LEGACY_RULES_FILE;
pub(super) fn rule_sources(repo_root: &Path) -> io::Result<Vec<PathBuf>> {
let mut sources = Vec::new();
let legacy_rules = repo_root.join(CURSOR_LEGACY_RULES_FILE);
if is_non_empty_text_file(&legacy_rules)? {
sources.push(legacy_rules);
}
let rules_dir = repo_root.join(CURSOR_AGENT_DIR).join("rules");
if rules_dir.is_dir() {
for entry in fs::read_dir(rules_dir)? {
let entry = entry?;
let path = entry.path();
if !entry.file_type()?.is_file()
|| !matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("md" | "mdc")
)
{
continue;
}
let contents = fs::read_to_string(&path)?;
if rule_is_always_applied(&contents)
&& !strip_markdown_frontmatter(&contents).trim().is_empty()
{
sources.push(path);
}
}
}
sources.sort();
Ok(sources)
}
fn rule_is_always_applied(contents: &str) -> bool {
let Some(frontmatter) = markdown_frontmatter(contents) else {
return false;
};
frontmatter.lines().any(|line| {
line.split_once(':').is_some_and(|(key, value)| {
key.trim() == "alwaysApply" && value.trim().eq_ignore_ascii_case("true")
})
})
}
fn markdown_frontmatter(contents: &str) -> Option<&str> {
let contents = contents.strip_prefix("---")?;
let contents = contents
.strip_prefix("\r\n")
.or_else(|| contents.strip_prefix('\n'))?;
contents
.find("\n---")
.map(|frontmatter_end| &contents[..frontmatter_end])
}
pub(super) fn strip_markdown_frontmatter(contents: &str) -> &str {
let Some(contents) = contents.strip_prefix("---") else {
return contents;
};
let Some(contents) = contents
.strip_prefix("\r\n")
.or_else(|| contents.strip_prefix('\n'))
else {
return contents;
};
let Some(frontmatter_end) = contents.find("\n---") else {
return contents;
};
contents[frontmatter_end + "\n---".len()..]
.strip_prefix("\r\n")
.or_else(|| contents[frontmatter_end + "\n---".len()..].strip_prefix('\n'))
.unwrap_or(&contents[frontmatter_end + "\n---".len()..])
}
fn is_non_empty_text_file(path: &Path) -> io::Result<bool> {
if !path.is_file() {
return Ok(false);
}
Ok(!fs::read_to_string(path)?.trim().is_empty())
}
@@ -8,7 +8,6 @@ use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as Co
use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use crate::config::external_agent_config::ExternalAgentConfigService;
use crate::config::external_agent_config::NamedMigration as CoreNamedMigration;
use crate::config::external_agent_config::PendingPluginImport;
use crate::config::external_agent_config::PluginImportOutcome;
use crate::config::external_agent_config::record_import_error;
use crate::config_manager::ConfigManager;
@@ -111,8 +110,10 @@ impl ExternalAgentConfigRequestProcessor {
&self,
params: ExternalAgentConfigDetectParams,
) -> Result<ExternalAgentConfigDetectResponse, JSONRPCErrorError> {
let items = self
let migration_service = self
.migration_service
.with_api_source(params.source.as_deref());
let items = migration_service
.detect(ExternalAgentConfigDetectOptions {
include_home: params.include_home,
cwds: params.cwds,
@@ -213,6 +214,9 @@ impl ExternalAgentConfigRequestProcessor {
) -> Result<(), JSONRPCErrorError> {
let import_id = Uuid::new_v4().to_string();
let analytics_source = params.source.clone().unwrap_or_default();
let migration_service = self
.migration_service
.with_api_source(params.source.as_deref());
let needs_runtime_refresh = migration_items_need_runtime_refresh(&params.migration_items);
let has_migration_items = !params.migration_items.is_empty();
let has_plugin_imports = params.migration_items.iter().any(|item| {
@@ -222,8 +226,10 @@ impl ExternalAgentConfigRequestProcessor {
)
});
let (pending_session_imports, session_validation_result) =
self.validate_pending_session_imports(&params);
let import_outcome = self.import_external_agent_config(params).await;
self.validate_pending_session_imports(&params, &migration_service);
let import_outcome = self
.import_external_agent_config(params, &migration_service)
.await;
if needs_runtime_refresh {
self.config_processor.handle_config_mutation().await;
}
@@ -266,7 +272,7 @@ impl ExternalAgentConfigRequestProcessor {
}
let session_importer = self.session_importer.clone();
let plugin_processor = self.clone();
let plugin_migration_service = migration_service;
let outgoing = Arc::clone(&self.outgoing);
let state_db = self.state_db.clone();
let analytics_events_client = self.analytics_events_client.clone();
@@ -301,8 +307,11 @@ impl ExternalAgentConfigRequestProcessor {
pending_plugin_import.description.clone(),
pending_plugin_import.cwd.clone(),
);
match plugin_processor
.complete_pending_plugin_import(pending_plugin_import)
match plugin_migration_service
.import_plugins(
pending_plugin_import.cwd.as_deref(),
Some(pending_plugin_import.details),
)
.await
{
Ok(plugin_outcome) => {
@@ -312,7 +321,7 @@ impl ExternalAgentConfigRequestProcessor {
record_import_error(
&mut item_result,
"plugin_import",
error.message.clone(),
error.to_string(),
/*source*/ None,
);
}
@@ -374,6 +383,7 @@ impl ExternalAgentConfigRequestProcessor {
fn validate_pending_session_imports(
&self,
params: &ExternalAgentConfigImportParams,
migration_service: &ExternalAgentConfigService,
) -> (Vec<CoreSessionMigration>, Option<CoreImportItemResult>) {
let sessions = params
.migration_items
@@ -403,33 +413,31 @@ impl ExternalAgentConfigRequestProcessor {
let mut selected_session_paths = HashSet::new();
let mut selected_sessions = Vec::new();
for session in sessions {
let canonical_path = match self
.migration_service
.external_agent_session_source_path(&session.path)
{
Ok(Some(canonical_path)) => canonical_path,
Ok(None) => {
record_import_error(
&mut item_result,
"session_missing",
format!(
"external agent session was not detected for import: {}",
session.path.display()
),
Some(session.path.display().to_string()),
);
continue;
}
Err(err) => {
record_import_error(
&mut item_result,
"session_source_path",
err.to_string(),
Some(session.path.display().to_string()),
);
continue;
}
};
let canonical_path =
match migration_service.external_agent_session_source_path(&session.path) {
Ok(Some(canonical_path)) => canonical_path,
Ok(None) => {
record_import_error(
&mut item_result,
"session_missing",
format!(
"external agent session was not detected for import: {}",
session.path.display()
),
Some(session.path.display().to_string()),
);
continue;
}
Err(err) => {
record_import_error(
&mut item_result,
"session_source_path",
err.to_string(),
Some(session.path.display().to_string()),
);
continue;
}
};
if selected_session_paths.insert(canonical_path) {
selected_sessions.push(session);
}
@@ -440,8 +448,9 @@ impl ExternalAgentConfigRequestProcessor {
async fn import_external_agent_config(
&self,
params: ExternalAgentConfigImportParams,
migration_service: &ExternalAgentConfigService,
) -> CoreImportOutcome {
self.migration_service
migration_service
.import(
params
.migration_items
@@ -541,19 +550,6 @@ impl ExternalAgentConfigRequestProcessor {
)
.await
}
async fn complete_pending_plugin_import(
&self,
pending_plugin_import: PendingPluginImport,
) -> Result<PluginImportOutcome, JSONRPCErrorError> {
self.migration_service
.import_plugins(
pending_plugin_import.cwd.as_deref(),
Some(pending_plugin_import.details),
)
.await
.map_err(|err| internal_error(err.to_string()))
}
}
async fn send_import_progress(
@@ -51,6 +51,133 @@ fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String
response.import_id
}
#[tokio::test]
async fn external_agent_config_imports_cursor_repo_artifacts() -> Result<()> {
let codex_home = TempDir::new()?;
let project_root = codex_home.path().join("repo");
let cursor_dir = project_root.join(".cursor");
std::fs::create_dir_all(project_root.join(".git"))?;
std::fs::create_dir_all(cursor_dir.join("skills/release"))?;
std::fs::create_dir_all(cursor_dir.join("commands"))?;
std::fs::create_dir_all(cursor_dir.join("agents"))?;
std::fs::create_dir_all(cursor_dir.join("rules"))?;
std::fs::write(
cursor_dir.join("mcp.json"),
r#"{"mcpServers":{"docs":{"command":"docs-server","args":["--stdio"]}}}"#,
)?;
std::fs::write(
cursor_dir.join("skills/release/SKILL.md"),
"---\nname: release\ndescription: Release with Cursor\n---\n\nUse Cursor to release.\n",
)?;
std::fs::write(
cursor_dir.join("commands/review.md"),
"---\ndescription: Review with Cursor\n---\nReview the change in Cursor.\n",
)?;
std::fs::write(
cursor_dir.join("agents/researcher.md"),
"---\nname: researcher\ndescription: Cursor research agent\n---\nResearch with Cursor.\n",
)?;
std::fs::write(
project_root.join(".cursorrules"),
"Use Cursor for repository work.\n",
)?;
std::fs::write(
cursor_dir.join("rules/always.mdc"),
"---\ndescription: Always apply\nalwaysApply: true\n---\nRun checks in Cursor.\n",
)?;
std::fs::write(
cursor_dir.join("rules/scoped.mdc"),
"---\ndescription: TypeScript only\nglobs: '*.ts'\nalwaysApply: false\n---\nUse TypeScript.\n",
)?;
let home_dir = codex_home.path().display().to_string();
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))])
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/detect",
Some(serde_json::json!({
"includeHome": false,
"cwds": [&project_root],
"source": "cursor"
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let detected: ExternalAgentConfigDetectResponse = to_response(response)?;
assert_eq!(
detected
.items
.iter()
.map(|item| item.item_type)
.collect::<Vec<_>>(),
vec![
ExternalAgentConfigMigrationItemType::McpServerConfig,
ExternalAgentConfigMigrationItemType::Skills,
ExternalAgentConfigMigrationItemType::Commands,
ExternalAgentConfigMigrationItemType::Subagents,
ExternalAgentConfigMigrationItemType::AgentsMd,
]
);
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"migrationItems": detected.items,
"source": "cursor"
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
let import_id = assert_import_response(response);
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
)
.await??;
let completed: ExternalAgentConfigImportCompletedNotification =
serde_json::from_value(notification.params.expect("completed params"))?;
assert_eq!(completed.import_id, import_id);
assert!(
completed
.item_type_results
.iter()
.all(|result| result.failures.is_empty())
);
let config = std::fs::read_to_string(project_root.join(".codex/config.toml"))?;
assert!(config.contains("[mcp_servers.docs]"));
assert!(config.contains("command = \"docs-server\""));
let skill = std::fs::read_to_string(project_root.join(".agents/skills/release/SKILL.md"))?;
assert!(skill.contains("Release with Codex"));
let command = std::fs::read_to_string(
project_root.join(".agents/skills/source-command-review/SKILL.md"),
)?;
assert!(command.contains("Review with Codex"));
let subagent = std::fs::read_to_string(project_root.join(".codex/agents/researcher.toml"))?;
assert!(subagent.contains("Codex research agent"));
let agents_md = std::fs::read_to_string(project_root.join("AGENTS.md"))?;
assert!(agents_md.contains("Use Codex for repository work."));
assert!(agents_md.contains("Run checks in Codex."));
assert!(!agents_md.contains("Use TypeScript."));
assert!(!agents_md.contains("alwaysApply"));
Ok(())
}
#[tokio::test]
async fn external_agent_config_import_sends_completion_notification_for_sync_only_import()
-> Result<()> {
+107 -36
View File
@@ -19,6 +19,12 @@ const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks";
const COMMAND_SKILL_PREFIX: &str = "source-command";
const MAX_SKILL_NAME_LEN: usize = 64;
#[derive(Clone, Copy)]
enum SourceAgent {
ClaudeCode,
Cursor,
}
#[derive(Debug)]
struct ParsedDocument {
frontmatter: BTreeMap<String, FrontmatterValue>,
@@ -46,6 +52,26 @@ pub fn build_mcp_config_from_external(
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
let mcp_servers = read_external_mcp_servers(source_root, external_agent_home)?;
build_mcp_config(mcp_servers, settings)
}
pub fn build_mcp_config_from_cursor(cursor_dir: &Path) -> io::Result<TomlValue> {
let source_file = cursor_dir.join("mcp.json");
if !source_file.is_file() {
return Ok(TomlValue::Table(Default::default()));
}
let raw = fs::read_to_string(&source_file)?;
let parsed: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?;
let mut mcp_servers = BTreeMap::new();
append_mcp_servers_from_value(&parsed, &mut mcp_servers, McpServerMerge::Overwrite);
build_mcp_config(mcp_servers, /*settings*/ None)
}
fn build_mcp_config(
mcp_servers: BTreeMap<String, JsonValue>,
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
if mcp_servers.is_empty() {
return Ok(TomlValue::Table(Default::default()));
}
@@ -155,6 +181,21 @@ pub fn missing_subagent_names(
}
pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<Vec<String>> {
import_subagents_for_source(source_agents, target_agents, SourceAgent::ClaudeCode)
}
pub fn import_cursor_subagents(
source_agents: &Path,
target_agents: &Path,
) -> io::Result<Vec<String>> {
import_subagents_for_source(source_agents, target_agents, SourceAgent::Cursor)
}
fn import_subagents_for_source(
source_agents: &Path,
target_agents: &Path,
source: SourceAgent,
) -> io::Result<Vec<String>> {
if !source_agents.is_dir() {
return Ok(Vec::new());
}
@@ -172,7 +213,10 @@ pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Resul
let Some(metadata) = agent_metadata(&document) else {
continue;
};
fs::write(&target, render_agent_toml(&document.body, &metadata)?)?;
fs::write(
&target,
render_agent_toml(&document.body, &metadata, source)?,
)?;
imported.push(metadata.name);
}
@@ -195,6 +239,21 @@ pub fn missing_command_names(
}
pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result<Vec<String>> {
import_commands_for_source(source_commands, target_skills, SourceAgent::ClaudeCode)
}
pub fn import_cursor_commands(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
import_commands_for_source(source_commands, target_skills, SourceAgent::Cursor)
}
fn import_commands_for_source(
source_commands: &Path,
target_skills: &Path,
source: SourceAgent,
) -> io::Result<Vec<String>> {
if !source_commands.is_dir() {
return Ok(Vec::new());
}
@@ -214,7 +273,7 @@ pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Resu
};
fs::write(
target_dir.join("SKILL.md"),
render_command_skill(&document.body, &name, &description, &source_name),
render_command_skill(&document.body, &name, &description, &source_name, source),
)?;
imported.push(name);
}
@@ -627,7 +686,10 @@ fn append_convertible_hook_groups(
{
command_payload.insert(
"statusMessage".to_string(),
JsonValue::String(rewrite_external_agent_terms(status_message)),
JsonValue::String(rewrite_external_agent_terms(
status_message,
SourceAgent::ClaudeCode,
)),
);
}
hook_commands.push(JsonValue::Object(command_payload));
@@ -1069,12 +1131,16 @@ fn agent_metadata(document: &ParsedDocument) -> Option<AgentMetadata> {
})
}
fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result<String> {
fn render_agent_toml(
body: &str,
metadata: &AgentMetadata,
source: SourceAgent,
) -> io::Result<String> {
let mut document = toml::map::Map::new();
document.insert("name".to_string(), TomlValue::String(metadata.name.clone()));
document.insert(
"description".to_string(),
TomlValue::String(rewrite_external_agent_terms(&metadata.description)),
TomlValue::String(rewrite_external_agent_terms(&metadata.description, source)),
);
if let Some(effort) = metadata.effort.as_ref()
&& let Some(effort) = map_agent_reasoning_effort(effort)
@@ -1096,7 +1162,7 @@ fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result<String>
}
document.insert(
"developer_instructions".to_string(),
TomlValue::String(render_agent_body(body)),
TomlValue::String(render_agent_body(body, source)),
);
let serialized = toml::to_string_pretty(&TomlValue::Table(document))
@@ -1104,8 +1170,8 @@ fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result<String>
Ok(format!("{}\n", serialized.trim_end()))
}
fn render_agent_body(body: &str) -> String {
let body = rewrite_external_agent_terms(body.trim());
fn render_agent_body(body: &str, source: SourceAgent) -> String {
let body = rewrite_external_agent_terms(body.trim(), source);
if body.is_empty() {
"No subagent instructions were found.".to_string()
} else {
@@ -1160,8 +1226,14 @@ fn command_source_name(source_commands: &Path, source_file: &Path) -> String {
.join("-")
}
fn render_command_skill(body: &str, name: &str, description: &str, source_name: &str) -> String {
let body = rewrite_external_agent_terms(body.trim());
fn render_command_skill(
body: &str,
name: &str,
description: &str,
source_name: &str,
source: SourceAgent,
) -> String {
let body = rewrite_external_agent_terms(body.trim(), source);
let template_body = if body.is_empty() {
"No command template body was found.".to_string()
} else {
@@ -1170,7 +1242,7 @@ fn render_command_skill(body: &str, name: &str, description: &str, source_name:
format!(
"---\nname: {}\ndescription: {}\n---\n\n# {name}\n\nUse this skill when the user asks to run the migrated source command `{source_name}`.\n\n## Command Template\n\n{template_body}\n",
yaml_string(name),
yaml_string(&rewrite_external_agent_terms(description)),
yaml_string(&rewrite_external_agent_terms(description, source)),
)
}
@@ -1291,14 +1363,24 @@ fn is_missing_or_empty_text_file(path: &Path) -> io::Result<bool> {
Ok(fs::read_to_string(path)?.trim().is_empty())
}
fn rewrite_external_agent_terms(content: &str) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(
content,
&external_agent_doc_file_name(),
"AGENTS.md",
);
for from in external_agent_term_variants() {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, &from, "Codex");
fn rewrite_external_agent_terms(content: &str, source: SourceAgent) -> String {
let (doc_file_name, term_variants): (&str, &[&str]) = match source {
SourceAgent::ClaudeCode => (
"CLAUDE.md",
&[
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
],
),
SourceAgent::Cursor => (".cursorrules", &["cursor agent", "cursor-agent", "cursor"]),
};
let mut rewritten =
replace_case_insensitive_with_boundaries(content, doc_file_name, "AGENTS.md");
for from in term_variants {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
}
rewritten
}
@@ -1365,20 +1447,6 @@ fn external_agent_project_dir_env_var() -> String {
)
}
fn external_agent_doc_file_name() -> String {
format!("{SOURCE_EXTERNAL_AGENT_NAME}.md")
}
fn external_agent_term_variants() -> [String; 5] {
[
format!("{SOURCE_EXTERNAL_AGENT_NAME} code"),
format!("{SOURCE_EXTERNAL_AGENT_NAME}-code"),
format!("{SOURCE_EXTERNAL_AGENT_NAME}_code"),
format!("{SOURCE_EXTERNAL_AGENT_NAME}code"),
SOURCE_EXTERNAL_AGENT_NAME.to_string(),
]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1738,6 +1806,7 @@ command = "enabled-server"
"source-command-review",
&description,
"review",
SourceAgent::ClaudeCode,
);
let rendered_document = parse_document_content(&rendered);
assert_eq!(
@@ -1817,9 +1886,11 @@ command = "enabled-server"
"---\nname: reviewer\ndescription: Review code\nmodel: source-opus\neffort: max\n---\nReview carefully.\n",
);
let metadata = agent_metadata(&document).expect("metadata");
let rendered: TomlValue =
toml::from_str(&render_agent_toml(&document.body, &metadata).expect("render agent"))
.expect("parse rendered agent");
let rendered: TomlValue = toml::from_str(
&render_agent_toml(&document.body, &metadata, SourceAgent::ClaudeCode)
.expect("render agent"),
)
.expect("parse rendered agent");
let expected: TomlValue = toml::from_str(
r#"
name = "reviewer"
@@ -216,6 +216,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
.external_agent_config_detect(ExternalAgentConfigDetectParams {
include_home: true,
cwds: Some(vec![cwd.clone()]),
source: None,
})
.await
{