default external imports without a source picker

This commit is contained in:
charlesgong-openai
2026-07-09 19:40:44 -04:00
parent 05b53f8dc0
commit 00e21e5036
19 changed files with 67 additions and 705 deletions
+2 -2
View File
@@ -239,8 +239,8 @@ Example with notification opt-out:
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`. Pass `source: "cursor"` to detect Cursor artifacts; omitted, `null`, or unrecognized values retain the default Claude Code behavior. 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 server reads from the matching product; `source: "cursor"` selects Cursor, while omitted, `null`, or unrecognized values retain the default Claude Code behavior. 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 an optional source selector. Omitted, `null`, or unrecognized source values retain the default behavior. 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 optional source used for detection so the server reads from the matching source; omitted, `null`, or unrecognized values retain the default behavior. 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`.
@@ -51,9 +51,6 @@ fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String
response.import_id
}
#[path = "external_agent_config_source_cu.rs"]
mod source_cu_tests;
#[tokio::test]
async fn external_agent_config_import_sends_completion_notification_for_sync_only_import()
-> Result<()> {
@@ -1,332 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn external_agent_config_imports_source_cu_repo_artifacts() -> Result<()> {
let codex_home = TempDir::new()?;
let project_root = codex_home.path().join("repo");
let source_dir = project_root.join(".cursor");
let nested_scope = project_root.join("backend");
std::fs::create_dir_all(project_root.join(".git"))?;
std::fs::create_dir_all(source_dir.join("skills/release"))?;
std::fs::create_dir_all(source_dir.join("commands"))?;
std::fs::create_dir_all(source_dir.join("agents"))?;
std::fs::create_dir_all(source_dir.join("rules/team"))?;
std::fs::create_dir_all(nested_scope.join(".cursor/rules"))?;
std::fs::write(
source_dir.join("mcp.json"),
r#"{"mcpServers":{"docs":{"command":"docs-server","args":["--stdio"]}}}"#,
)?;
std::fs::write(
source_dir.join("skills/release/SKILL.md"),
"---\nname: release\ndescription: Release with Cursor\n---\n\nUse Cursor to release. Advance the database cursor after each page.\n",
)?;
std::fs::write(
source_dir.join("commands/review.md"),
"Review the change with Cursor. Keep the text cursor visible.\n",
)?;
std::fs::write(
source_dir.join("agents/researcher.md"),
"---\nname: researcher\ndescription: Cursor research role\n---\nResearch with Cursor.\n",
)?;
std::fs::write(
project_root.join(".cursorrules"),
"Use Cursor for repository work. Keep the text cursor visible.\n",
)?;
std::fs::write(
source_dir.join("rules/always.mdc"),
"---\ndescription: Always apply\nalwaysApply: true # YAML comments are valid\n---\nRun checks in Cursor.\n",
)?;
std::fs::write(
source_dir.join("rules/team/database.mdc"),
"---\ndescription: Database guidance\nalwaysApply: true\n---\nDocument database cursor behavior.\n",
)?;
std::fs::write(
source_dir.join("rules/scoped.mdc"),
"---\ndescription: TypeScript only\nglobs: '*.ts'\nalwaysApply: false\n---\nUse TypeScript.\n",
)?;
std::fs::write(
nested_scope.join(".cursor/rules/always.mdc"),
"---\ndescription: Backend guidance\nalwaysApply: true\n---\nUse Cursor for backend work.\n",
)?;
let home_dir = codex_home.path().display().to_string();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("HOME", Some(home_dir.as_str()))])
.build()
.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,
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"));
assert!(skill.contains("database cursor"));
let command = std::fs::read_to_string(
project_root.join(".agents/skills/source-command-review/SKILL.md"),
)?;
assert!(command.contains("Review the change with Codex"));
assert!(command.contains("Migrated source command `review`"));
assert!(command.contains("text cursor"));
let subagent = std::fs::read_to_string(project_root.join(".codex/agents/researcher.toml"))?;
assert!(subagent.contains("Codex research role"));
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("Document database cursor behavior."));
assert!(agents_md.contains("text cursor"));
assert!(!agents_md.contains("Use TypeScript."));
assert!(!agents_md.contains("alwaysApply"));
let nested_agents_md = std::fs::read_to_string(nested_scope.join("AGENTS.md"))?;
assert!(nested_agents_md.contains("Use Codex for backend work."));
Ok(())
}
#[tokio::test]
async fn external_agent_config_imports_source_cu_home_artifacts() -> Result<()> {
let root = TempDir::new()?;
let home = root.path().join("home");
let codex_home = home.join(".codex");
let source_dir = home.join(".cursor");
std::fs::create_dir_all(&codex_home)?;
std::fs::create_dir_all(source_dir.join("skills/home-release"))?;
std::fs::create_dir_all(source_dir.join("commands"))?;
std::fs::create_dir_all(source_dir.join("agents"))?;
std::fs::create_dir_all(source_dir.join("rules"))?;
std::fs::write(
source_dir.join("mcp.json"),
r#"{"mcpServers":{"home-docs":{"command":"home-docs-server"}}}"#,
)?;
std::fs::write(
source_dir.join("skills/home-release/SKILL.md"),
"---\nname: home-release\ndescription: Release with Cursor\n---\n\nUse Cursor for home releases.\n",
)?;
std::fs::write(
source_dir.join("commands/home-review.md"),
"Review home changes with Cursor.\n",
)?;
std::fs::write(
source_dir.join("agents/home-researcher.md"),
"---\nname: home-researcher\ndescription: Research with Cursor\n---\nResearch with Cursor.\n",
)?;
std::fs::write(
source_dir.join("rules/always.mdc"),
"---\ndescription: Home guidance\nalwaysApply: true\n---\nUse Cursor for home work.\n",
)?;
let home_dir = home.display().to_string();
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.with_env_overrides(&[("HOME", Some(home_dir.as_str()))])
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/detect",
Some(serde_json::json!({
"includeHome": true,
"cwds": [],
"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(codex_home.join("config.toml"))?;
assert!(config.contains("[mcp_servers.home-docs]"));
let skill = std::fs::read_to_string(home.join(".agents/skills/home-release/SKILL.md"))?;
assert!(skill.contains("Release with Codex"));
assert!(skill.contains("Use Codex for home releases."));
let command =
std::fs::read_to_string(home.join(".agents/skills/source-command-home-review/SKILL.md"))?;
assert!(command.contains("Review home changes with Codex."));
let subagent = std::fs::read_to_string(codex_home.join("agents/home-researcher.toml"))?;
assert!(subagent.contains("Research with Codex"));
let agents_md = std::fs::read_to_string(codex_home.join("AGENTS.md"))?;
assert!(agents_md.contains("Use Codex for home work."));
Ok(())
}
#[tokio::test]
async fn source_cu_import_does_not_accept_source_cl_sessions() -> Result<()> {
let codex_home = TempDir::new()?;
let project_root = codex_home.path().join("repo");
let session_path = external_agent_home(codex_home.path()).join("projects/repo/session.jsonl");
std::fs::create_dir_all(session_path.parent().expect("session parent"))?;
std::fs::write(&session_path, "{}\n")?;
let home_dir = codex_home.path().display().to_string();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("HOME", Some(home_dir.as_str()))])
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"source": "cursor",
"migrationItems": [{
"itemType": "SESSIONS",
"description": "Migrate recent sessions",
"cwd": null,
"details": {
"sessions": [{
"path": session_path,
"cwd": project_root,
"title": "wrong source"
}]
}
}]
})),
)
.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_eq!(completed.item_type_results.len(), 1);
let result = &completed.item_type_results[0];
assert_eq!(
result.item_type,
ExternalAgentConfigMigrationItemType::Sessions
);
assert!(result.successes.is_empty());
assert_eq!(result.failures.len(), 1);
assert_eq!(result.failures[0].failure_stage, "session_missing");
Ok(())
}
+5 -5
View File
@@ -136,7 +136,8 @@ use uuid::Uuid;
const JSONRPC_INVALID_REQUEST: i64 = -32600;
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = "A previous external agent import is still running. Wait for it to finish before importing again.";
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str =
"A previous Claude Code import is still running. Wait for it to finish before importing again.";
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
@@ -397,13 +398,12 @@ impl AppServerSession {
self.client
.request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params })
.await
.wrap_err("externalAgentConfig/detect failed during external agent import")
.wrap_err("externalAgentConfig/detect failed during Claude Code import")
}
pub(crate) async fn external_agent_config_import(
&mut self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
source: Option<String>,
) -> Result<()> {
// Mark the import active before sending the request so a fast completion notification
// cannot arrive before the TUI records it.
@@ -420,11 +420,11 @@ impl AppServerSession {
request_id,
params: ExternalAgentConfigImportParams {
migration_items,
source,
source: None,
},
})
.await
.wrap_err("externalAgentConfig/import failed during external agent import");
.wrap_err("externalAgentConfig/import failed during Claude Code import");
match response {
Ok(_) => Ok(()),
Err(err) => {
@@ -78,14 +78,15 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
let error_height = u16::from(self.error.is_some());
let intro_lines = match self.view {
MigrationView::Summary => vec![
Line::from("Bring over supported setup from another coding agent."),
Line::from("Bring over your setup, current project, and recent chats."),
Line::from("Codex may add files to your current project folder."),
Line::from("Your existing setup will not be changed."),
Line::from("Your existing Claude Code setup will not be changed."),
Line::from("Standard Claude Chat data cannot be imported."),
],
MigrationView::Customize => vec![
Line::from("Choose items to import."),
Line::from("Choose the Claude Code items to import."),
Line::from("Codex may add files to your current project folder."),
Line::from("Your existing setup will not be changed."),
Line::from("Your existing Claude Code setup will not be changed."),
],
};
let intro_height = intro_lines.len() as u16;
@@ -118,7 +119,7 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
.areas(inner_area);
let title = match self.view {
MigrationView::Summary => "Import setup",
MigrationView::Summary => "Import from Claude Code",
MigrationView::Customize => "Choose what to import",
};
let heading = Line::from(vec!["> ".into(), title.bold()]);
@@ -4,8 +4,6 @@ use crate::external_agent_config_migration::ExternalAgentConfigMigrationOutcome;
use crate::external_agent_config_migration::run_external_agent_config_migration_prompt;
use crate::external_agent_config_migration_model::external_agent_config_migration_item_count;
use crate::external_agent_config_migration_model::external_agent_config_migration_type_label;
use crate::external_agent_config_migration_source::ExternalAgentConfigMigrationSource;
use crate::external_agent_config_migration_source::run_external_agent_config_source_prompt;
use crate::legacy_core::config::Config;
use crate::tui;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
@@ -16,9 +14,9 @@ use ratatui::prelude::Stylize as _;
use ratatui::text::Line;
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE: &str =
"No compatible setup was found to import.";
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable in remote sessions. Start Codex locally and run /import.";
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.";
"No Claude Code setup was found to import.";
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE: &str = "Import from Claude Code is unavailable in remote sessions. Start Codex locally and run /import.";
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from Claude Code is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.";
pub(crate) enum ExternalAgentConfigMigrationFlowOutcome {
Started(Vec<Line<'static>>),
@@ -26,11 +24,6 @@ pub(crate) enum ExternalAgentConfigMigrationFlowOutcome {
Cancelled,
}
struct DetectedExternalAgentConfigSource {
source: ExternalAgentConfigMigrationSource,
items: Vec<ExternalAgentConfigMigrationItem>,
}
fn external_agent_config_migration_started_lines(
selected_items: &[ExternalAgentConfigMigrationItem],
remaining_item_count: usize,
@@ -96,7 +89,7 @@ fn external_agent_config_migration_started_lines(
let mut lines = vec![
vec![
"• ".dim(),
"Import started.".cyan(),
"Claude Code import started.".cyan(),
" You can keep working while it finishes.".into(),
]
.into(),
@@ -152,7 +145,7 @@ pub(crate) fn external_agent_config_migration_finished_lines(
let mut lines = vec![
vec![
"• ".dim(),
"Import finished: ".into(),
"Claude Code import finished: ".into(),
format!("{imported_count} imported").green(),
", ".into(),
failed_count,
@@ -219,65 +212,28 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
}
let cwd = config.cwd.to_path_buf();
let mut detected_sources = Vec::new();
let mut detection_errors = Vec::new();
for source in ExternalAgentConfigMigrationSource::ALL {
let response = match app_server
.external_agent_config_detect(ExternalAgentConfigDetectParams {
include_home: true,
cwds: Some(vec![cwd.clone()]),
source: Some(source.api_source().to_string()),
})
.await
{
Ok(response) => response,
Err(err) => {
tracing::warn!(
error = %err,
cwd = %cwd.display(),
source = source.label(),
"failed to detect external agent config migrations"
);
detection_errors.push(format!("{}: {err}", source.label()));
continue;
}
};
if !response.items.is_empty() {
detected_sources.push(DetectedExternalAgentConfigSource {
source,
items: response.items,
});
let detected_items = match app_server
.external_agent_config_detect(ExternalAgentConfigDetectParams {
include_home: true,
cwds: Some(vec![cwd.clone()]),
})
.await
{
Ok(response) => response.items,
Err(err) => {
tracing::warn!(
error = %err,
cwd = %cwd.display(),
"failed to detect external agent config migrations"
);
return Err(format!("Could not check for Claude Code setup: {err}"));
}
}
};
if detected_sources.is_empty() {
if !detection_errors.is_empty() {
return Err(format!(
"Could not check for importable setup: {}",
detection_errors.join("; ")
));
}
if detected_items.is_empty() {
return Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems);
}
let selected_source = if detected_sources.len() == 1 {
detected_sources[0].source
} else {
let sources = detected_sources
.iter()
.map(|detected| detected.source)
.collect::<Vec<_>>();
let Some(source) = run_external_agent_config_source_prompt(tui, &sources).await else {
return Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled);
};
source
};
let detected_items = detected_sources
.into_iter()
.find(|detected| detected.source == selected_source)
.map(|detected| detected.items)
.unwrap_or_default();
let mut selected_items = detected_items.clone();
let mut error: Option<String> = None;
@@ -292,13 +248,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
{
ExternalAgentConfigMigrationOutcome::Proceed(items) => {
selected_items = items.clone();
match app_server
.external_agent_config_import(
items,
Some(selected_source.api_source().to_string()),
)
.await
{
match app_server.external_agent_config_import(items).await {
Ok(()) => {
let remaining_item_count =
detected_items.len().saturating_sub(selected_items.len());
@@ -173,7 +173,7 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
vec![
Line::from(vec![
"• ".dim(),
"Import started.".cyan(),
"Claude Code import started.".cyan(),
" You can keep working while it finishes.".into(),
]),
Line::from(vec![
@@ -227,7 +227,7 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
vec![
Line::from(vec![
"• ".dim(),
"Import finished: ".into(),
"Claude Code import finished: ".into(),
"2 imported".green(),
", ".into(),
"1 failed".red(),
@@ -79,7 +79,7 @@ pub(crate) fn external_agent_config_migration_item_label(
item: &ExternalAgentConfigMigrationItem,
) -> &'static str {
match item.item_type {
ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions",
ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions (CLAUDE.md -> AGENTS.md)",
ExternalAgentConfigMigrationItemType::Config => "Settings (settings.json -> config.toml)",
ExternalAgentConfigMigrationItemType::Skills => "Skills",
ExternalAgentConfigMigrationItemType::Plugins => "Plugins",
@@ -1,200 +0,0 @@
use crate::key_hint;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Stylize as _;
use ratatui::prelude::Widget;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use tokio_stream::StreamExt;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ExternalAgentConfigMigrationSource {
Cl,
Cu,
}
impl ExternalAgentConfigMigrationSource {
pub(crate) const ALL: [Self; 2] = [Self::Cl, Self::Cu];
pub(crate) fn api_source(self) -> &'static str {
match self {
Self::Cl => "claude-code",
Self::Cu => "cursor",
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Self::Cl => "Claude Code",
Self::Cu => "Cursor",
}
}
}
pub(crate) async fn run_external_agent_config_source_prompt(
tui: &mut Tui,
sources: &[ExternalAgentConfigMigrationSource],
) -> Option<ExternalAgentConfigMigrationSource> {
let mut screen = ExternalAgentConfigSourceScreen::new(tui.frame_requester(), sources);
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
let events = tui.event_stream();
tokio::pin!(events);
while !screen.is_done() {
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw | TuiEvent::Resize => {
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
}
}
} else {
screen.cancel();
}
}
screen.selection()
}
struct ExternalAgentConfigSourceScreen {
request_frame: FrameRequester,
sources: Vec<ExternalAgentConfigMigrationSource>,
highlighted_idx: usize,
selection: Option<ExternalAgentConfigMigrationSource>,
done: bool,
}
impl ExternalAgentConfigSourceScreen {
fn new(request_frame: FrameRequester, sources: &[ExternalAgentConfigMigrationSource]) -> Self {
Self {
request_frame,
sources: sources.to_vec(),
highlighted_idx: 0,
selection: None,
done: sources.is_empty(),
}
}
fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release || self.sources.is_empty() {
return;
}
if key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
{
self.cancel();
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => self.move_highlight(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_highlight(1),
KeyCode::Char(character) if character.is_ascii_digit() => {
let Some(index) = character
.to_digit(10)
.and_then(|index| index.checked_sub(1))
else {
return;
};
self.select(index as usize);
}
KeyCode::Enter => self.select(self.highlighted_idx),
KeyCode::Esc => self.cancel(),
_ => {}
}
}
fn move_highlight(&mut self, delta: isize) {
self.highlighted_idx = self
.highlighted_idx
.saturating_add_signed(delta)
.min(self.sources.len().saturating_sub(1));
self.request_frame.schedule_frame();
}
fn select(&mut self, index: usize) {
let Some(source) = self.sources.get(index).copied() else {
return;
};
self.selection = Some(source);
self.done = true;
self.request_frame.schedule_frame();
}
fn cancel(&mut self) {
self.selection = None;
self.done = true;
self.request_frame.schedule_frame();
}
fn is_done(&self) -> bool {
self.done
}
fn selection(&self) -> Option<ExternalAgentConfigMigrationSource> {
self.selection
}
}
impl WidgetRef for &ExternalAgentConfigSourceScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
column.push("Choose an import source".bold());
column.push("");
column.push(
Line::from("Select the app whose setup you want to import.".dim()).inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
for (index, source) in self.sources.iter().enumerate() {
let prefix = if index == self.highlighted_idx {
"›"
} else {
" "
};
let label = Span::from(format!("{prefix} {}. {}", index + 1, source.label()));
column.push(Line::from(if index == self.highlighted_idx {
label.cyan()
} else {
label
}));
}
column.push("");
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
])
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.render(area, buf);
}
}
#[cfg(test)]
#[path = "external_agent_config_migration_source_tests.rs"]
mod tests;
@@ -1,42 +0,0 @@
use super::*;
use crate::test_backend::VT100Backend;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
fn new_screen() -> ExternalAgentConfigSourceScreen {
ExternalAgentConfigSourceScreen::new(
FrameRequester::test_dummy(),
&ExternalAgentConfigMigrationSource::ALL,
)
}
#[test]
fn external_agent_config_source_prompt_snapshot() {
let screen = new_screen();
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 12)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render source prompt");
insta::assert_snapshot!("external_agent_config_source_prompt", terminal.backend());
}
#[test]
fn external_agent_config_source_prompt_selects_highlighted_source() {
let mut screen = new_screen();
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
screen.selection(),
Some(ExternalAgentConfigMigrationSource::Cu)
);
}
#[test]
fn external_agent_config_source_prompt_can_cancel() {
let mut screen = new_screen();
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(screen.selection(), None);
}
-1
View File
@@ -116,7 +116,6 @@ mod exec_command;
mod external_agent_config_migration;
mod external_agent_config_migration_flow;
mod external_agent_config_migration_model;
mod external_agent_config_migration_source;
mod external_editor;
mod file_search;
mod frames;
@@ -4,9 +4,9 @@ expression: rendered
---
> Choose what to import
Choose items to import.
Choose the Claude Code items to import.
Codex may add files to your current project folder.
Your existing setup will not be changed.
Your existing Claude Code setup will not be changed.
Home
› [x] Settings (settings.json -> config.toml)
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
@@ -21,7 +21,7 @@ expression: rendered
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Instructions
[x] Instructions (CLAUDE.md -> AGENTS.md)
Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items.
@@ -4,9 +4,9 @@ expression: rendered
---
> Choose what to import
Choose items to import.
Choose the Claude Code items to import.
Codex may add files to your current project folder.
Your existing setup will not be changed.
Your existing Claude Code setup will not be changed.
Home
[x] Settings (settings.json -> config.toml)
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
@@ -21,7 +21,7 @@ expression: rendered
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Instructions
[x] Instructions (CLAUDE.md -> AGENTS.md)
Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items.
@@ -4,9 +4,9 @@ expression: rendered
---
> Choose what to import
Choose items to import.
Choose the Claude Code items to import.
Codex may add files to your current project folder.
Your existing setup will not be changed.
Your existing Claude Code setup will not be changed.
Home
[x] Settings (settings.json -> config.toml)
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
@@ -21,7 +21,7 @@ expression: rendered
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Instructions
[x] Instructions (CLAUDE.md -> AGENTS.md)
Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items.
@@ -4,9 +4,9 @@ expression: rendered
---
> Choose what to import
Choose items to import.
Choose the Claude Code items to import.
Codex may add files to your current project folder.
Your existing setup will not be changed.
Your existing Claude Code setup will not be changed.
Home
› [x] Settings (settings.json -> config.toml)
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
@@ -21,7 +21,7 @@ expression: rendered
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Instructions
[x] Instructions (CLAUDE.md -> AGENTS.md)
Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items.
@@ -3,10 +3,11 @@ source: tui/src/external_agent_config_migration.rs
expression: rendered
---
> Import setup
Bring over supported setup from another coding agent.
> Import from Claude Code
Bring over your setup, current project, and recent chats.
Codex may add files to your current project folder.
Your existing setup will not be changed.
Your existing Claude Code setup will not be changed.
Standard Claude Chat data cannot be imported.
[x] Tools & setup
Settings, instructions, integrations, agents, commands, and skills
Importing: Settings 1
@@ -3,10 +3,11 @@ source: tui/src/external_agent_config_migration.rs
expression: rendered
---
> Import setup
Bring over supported setup from another coding agent.
> Import from Claude Code
Bring over your setup, current project, and recent chats.
Codex may add files to your current project folder.
Your existing setup will not be changed.
Your existing Claude Code setup will not be changed.
Standard Claude Chat data cannot be imported.
[x] Tools & setup
Settings, instructions, integrations, agents, commands, and skills
Importing: Settings 1
@@ -2,7 +2,7 @@
source: tui/src/external_agent_config_migration_flow_tests.rs
expression: messages
---
• Import started. You can keep working while it finishes.
• Claude Code import started. You can keep working while it finishes.
Imported setup will apply to new chats.
Importing:
Settings: 1
@@ -10,7 +10,7 @@ expression: messages
MCP servers: 2 — docs, issues
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
Plugins: 2 — formatter, reviewer
• Import started. You can keep working while it finishes.
• Claude Code import started. You can keep working while it finishes.
Imported setup will apply to new chats.
Importing:
Settings: 1
@@ -19,7 +19,7 @@ expression: messages
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
Plugins: 2 — formatter, reviewer
1 additional item remains. After it finishes, run /import again to review it.
• Import started. You can keep working while it finishes.
• Claude Code import started. You can keep working while it finishes.
Imported setup will apply to new chats.
Importing:
Settings: 1
@@ -28,12 +28,12 @@ expression: messages
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
Plugins: 2 — formatter, reviewer
2 additional items remain. After it finishes, run /import again to review them.
• Import finished: 2 imported, 1 failed.
• Claude Code import finished: 2 imported, 1 failed.
Results by type:
Settings: 1 imported, 0 failed
Plugins: 1 imported, 1 failed
Run /import again to check for additional items.
No compatible setup was found to import.
Import from other apps is unavailable in remote sessions. Start Codex locally and run /import.
Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.
A previous external agent import is still running. Wait for it to finish before importing again.
No Claude Code setup was found to import.
Import from Claude Code is unavailable in remote sessions. Start Codex locally and run /import.
Import from Claude Code is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.
A previous Claude Code import is still running. Wait for it to finish before importing again.
@@ -1,13 +0,0 @@
---
source: tui/src/external_agent_config_migration_source_tests.rs
expression: terminal.backend()
---
Choose an import source
Select the app whose setup you want to import.
› 1. Claude Code
2. Cursor
Press enter to continue