mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Support another external agent import source
This commit is contained in:
+8
-1
@@ -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"
|
||||
|
||||
+8
-1
@@ -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"
|
||||
|
||||
+8
-1
@@ -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"
|
||||
|
||||
+7
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+6
-1
@@ -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, };
|
||||
|
||||
+2
-1
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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(¶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<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<()> {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user