From 9e242a5eab5a30384e4726dff37f6fcc932f61fc Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 8 Jul 2026 12:05:53 -0400 Subject: [PATCH] Support another external agent import source --- .../schema/json/ClientRequest.json | 9 +- .../codex_app_server_protocol.schemas.json | 9 +- .../codex_app_server_protocol.v2.schemas.json | 9 +- .../v2/ExternalAgentConfigDetectParams.json | 7 + .../v2/ExternalAgentConfigImportParams.json | 2 +- .../v2/ExternalAgentConfigDetectParams.ts | 7 +- .../v2/ExternalAgentConfigImportParams.ts | 3 +- .../src/protocol/v2/config.rs | 7 +- codex-rs/app-server/README.md | 4 +- .../src/config/external_agent_config.rs | 317 +++++++++++++----- .../config/external_agent_config/cursor.rs | 86 +++++ .../external_agent_config_processor.rs | 94 +++--- .../tests/suite/v2/external_agent_config.rs | 127 +++++++ codex-rs/external-agent-migration/src/lib.rs | 143 ++++++-- .../external_agent_config_migration_flow.rs | 1 + 15 files changed, 644 insertions(+), 181 deletions(-) create mode 100644 codex-rs/app-server/src/config/external_agent_config/cursor.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d391cac165..490ec5a0d8 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b7526b080d..3956104bfd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e1afc7883a..a7a738cab4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json index 01fda3b071..0ae43e9515 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index d02241f95b..8bdc470bcf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts index 48b55e9b9a..45c8a7c316 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts @@ -10,4 +10,9 @@ includeHome?: boolean, /** * Zero or more working directories to include for repo-scoped detection. */ -cwds?: Array | null, }; +cwds?: Array | null, +/** + * Source product to detect. Missing or unrecognized values use Claude Code for backwards + * compatibility; `cursor` selects Cursor. + */ +source?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts index be7f7ffe30..2f69deb23e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts @@ -5,6 +5,7 @@ import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr export type ExternalAgentConfigImportParams = { migrationItems: Array, /** - * 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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 24d574a935..519bf458a8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -684,6 +684,10 @@ pub struct ExternalAgentConfigDetectParams { /// Zero or more working directories to include for repo-scoped detection. #[ts(optional = nullable)] pub cwds: Option>, + /// Source product to detect. Missing or unrecognized values use Claude Code for backwards + /// compatibility; `cursor` selects Cursor. + #[ts(optional = nullable)] + pub source: Option, } #[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, - /// 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, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6ee6baf2dc..e2c50b9adf 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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`. diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index ca1023e529..16383e14a6 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -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> { + 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, ) -> 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, + ) -> io::Result { + 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> { + 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> { + 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, Option)> { - 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::>(); - 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> { + 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> { 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> { + 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> { 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> { 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> { 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::>>()? + .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> { @@ -1620,7 +1728,31 @@ fn find_repo_agents_md_source(repo_root: &Path) -> io::Result> { Ok(None) } -fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> { +fn read_agents_md_source(path: &Path, source: ExternalAgentSource) -> io::Result { + 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::>() + .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 diff --git a/codex-rs/app-server/src/config/external_agent_config/cursor.rs b/codex-rs/app-server/src/config/external_agent_config/cursor.rs new file mode 100644 index 0000000000..38e5b4da29 --- /dev/null +++ b/codex-rs/app-server/src/config/external_agent_config/cursor.rs @@ -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> { + 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 { + if !path.is_file() { + return Ok(false); + } + Ok(!fs::read_to_string(path)?.trim().is_empty()) +} diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 5bae994ca5..deb09e0466 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -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 { - 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(¶ms.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(¶ms); - let import_outcome = self.import_external_agent_config(params).await; + self.validate_pending_session_imports(¶ms, &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, Option) { 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 { - 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( diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 0b366e79f2..43bc7aeda4 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -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![ + 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<()> { diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index 7e5f626d8d..210709f8f3 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -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, @@ -46,6 +52,26 @@ pub fn build_mcp_config_from_external( settings: Option<&JsonValue>, ) -> io::Result { 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 { + 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, + settings: Option<&JsonValue>, +) -> io::Result { 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> { + import_subagents_for_source(source_agents, target_agents, SourceAgent::ClaudeCode) +} + +pub fn import_cursor_subagents( + source_agents: &Path, + target_agents: &Path, +) -> io::Result> { + 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> { 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> { + import_commands_for_source(source_commands, target_skills, SourceAgent::ClaudeCode) +} + +pub fn import_cursor_commands( + source_commands: &Path, + target_skills: &Path, +) -> io::Result> { + 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> { 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 { }) } -fn render_agent_toml(body: &str, metadata: &AgentMetadata) -> io::Result { +fn render_agent_toml( + body: &str, + metadata: &AgentMetadata, + source: SourceAgent, +) -> io::Result { 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 } 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 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 { 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" diff --git a/codex-rs/tui/src/external_agent_config_migration_flow.rs b/codex-rs/tui/src/external_agent_config_migration_flow.rs index c1cef68dfd..3d1dadf0bc 100644 --- a/codex-rs/tui/src/external_agent_config_migration_flow.rs +++ b/codex-rs/tui/src/external_agent_config_migration_flow.rs @@ -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 {